diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fd10d81a57..6a05f029ff 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -79,7 +79,7 @@ jobs: BUCKET: s3://${{ secrets.AWS_REVIEW_BUCKET_NAME }}/walletweb/${{ steps.extract_branch.outputs.branch }} run: bash ./scripts/github/s3_upload.sh - # Comnment + # Comment - name: Post a deployment link in the PR if: always() && github.event.number uses: mshick/add-pr-comment@v2 diff --git a/cypress/e2e/create_safe.cy.js b/cypress/e2e/create_safe.cy.js index cf0d83f4b7..04ea76eefc 100644 --- a/cypress/e2e/create_safe.cy.js +++ b/cypress/e2e/create_safe.cy.js @@ -8,7 +8,7 @@ describe('Create Safe', () => { cy.contains('button', 'Accept all').click() // Ensure wallet is connected to correct chain via header - cy.contains('E2E Wallet @ Görli') + cy.contains('E2E Wallet @ Goerli') cy.contains('Create new Safe').click() diff --git a/cypress/e2e/safe-apps/tx_modal.cy.js b/cypress/e2e/safe-apps/tx_modal.cy.js index 973ef7dc5d..bf36767fc2 100644 --- a/cypress/e2e/safe-apps/tx_modal.cy.js +++ b/cypress/e2e/safe-apps/tx_modal.cy.js @@ -1,9 +1,7 @@ -import { TEST_SAFE } from './constants' - const appUrl = 'https://safe-test-app.com' describe('The transaction modal', () => { - before(() => { + beforeEach(() => { cy.fixture('safe-app').then((html) => { cy.intercept('GET', `${appUrl}/*`, html) cy.intercept('GET', `*/manifest.json`, { @@ -12,21 +10,15 @@ describe('The transaction modal', () => { icons: [{ src: 'logo.svg', sizes: 'any', type: 'image/svg+xml' }], }) }) - - cy.visitSafeApp(`${appUrl}/dummy`) - - cy.findByText(/accept selection/i).click() }) describe('When sending a transaction from an app', () => { it('should show the transaction popup', { defaultCommandTimeout: 12000 }, () => { - cy.findByRole('dialog').within(() => { - cy.findByText(/sending from/i) - - const testSafeParts = TEST_SAFE.split(':') + cy.visitSafeApp(`${appUrl}/dummy`) - cy.findByText(`${testSafeParts[0]}:`) - cy.findByText(testSafeParts[1]) + cy.findByText(/accept selection/i).click() + cy.findByRole('dialog').within(() => { + cy.findByText(/Cypress Test App/i) }) }) }) diff --git a/cypress/e2e/smoke/create_tx.cy.js b/cypress/e2e/smoke/create_tx.cy.js index ee09b6b43f..49d2f9a66d 100644 --- a/cypress/e2e/smoke/create_tx.cy.js +++ b/cypress/e2e/smoke/create_tx.cy.js @@ -1,9 +1,7 @@ const SAFE = 'gor:0x04f8b1EA3cBB315b87ced0E32deb5a43cC151a91' const EOA = '0xE297437d6b53890cbf004e401F3acc67c8b39665' -// generate number between 0.00001 and 0.00020 -let recommendedNonce -const sendValue = Math.floor(Math.random() * 20 + 1) / 100000 +const sendValue = 0.00002 const currentNonce = 3 describe('Queue a transaction on 1/N', () => { @@ -28,7 +26,7 @@ describe('Queue a transaction on 1/N', () => { cy.contains('New transaction').click() // Modal is open - cy.contains('h2', 'New transaction').should('be.visible') + cy.contains('h1', 'New transaction').should('be.visible') cy.contains('Send tokens').click() // Fill transaction data @@ -50,59 +48,28 @@ describe('Queue a transaction on 1/N', () => { .next() .then((element) => { const maxBalance = element.text().replace(' GOR', '').trim() - cy.wrap(element) - .parents('form') - .find('label') - .contains('Amount') - .next() - .find('input') - .should('have.value', maxBalance) - .clear() - .type(sendValue) + cy.get('input[name="amount"]').should('have.value', maxBalance) }) + cy.get('input[name="amount"]').clear().type(sendValue) + cy.contains('Next').click() }) it('should create a queued transaction', () => { - // Wait for /estimations response - cy.intercept('POST', '/**/multisig-transactions/estimations').as('EstimationRequest') - - cy.wait('@EstimationRequest') - - // Alias for New transaction modal - cy.contains('h2', 'Review transaction').parents('div').as('modal') - - // Estimation is loaded cy.get('button[type="submit"]').should('not.be.disabled') - // Gets the recommended nonce - cy.contains('Signing the transaction with nonce').should(($div) => { - // get the number in the string - recommendedNonce = $div.text().match(/\d+$/)[0] - }) + cy.contains('Native token transfer').should('be.visible') // Changes nonce to next one - cy.contains('Signing the transaction with nonce').click() - cy.contains('button', 'Edit').click() - cy.get('label').contains('Safe Account transaction nonce').next().clear().type(currentNonce) - cy.contains('Confirm').click() - - // Asserts the execute checkbox exists - cy.get('@modal').within(() => { - cy.get('input[type="checkbox"]') - .parent('span') - .should(($div) => { - // Turn the classList into a string - const classListString = Array.from($div[0].classList).join() - // Check if it contains the error class - expect(classListString).to.include('checked') - }) - }) + cy.get('input[name="nonce"]').clear().type(currentNonce, { force: true }).type('{enter}', { force: true }) + + // Execution + cy.contains('Yes, ').should('exist') cy.contains('Estimated fee').should('exist') // Asserting the sponsored info is present - cy.contains('Sponsored by').should('be.visible') + cy.contains('Execute').should('be.visible') cy.get('span').contains('Estimated fee').next().should('have.css', 'text-decoration-line', 'line-through') cy.contains('Transactions per hour') @@ -110,7 +77,7 @@ describe('Queue a transaction on 1/N', () => { cy.contains('Estimated fee').click() cy.contains('Edit').click() - cy.contains('Owner transaction (Execution)').parents('form').as('Paramsform') + cy.contains('Execution parameters').parents('form').as('Paramsform') // Only gaslimit should be editable when the relayer is selected const arrayNames = ['Wallet nonce', 'Max priority fee (Gwei)', 'Max fee (Gwei)'] @@ -125,37 +92,16 @@ describe('Queue a transaction on 1/N', () => { .invoke('prop', 'value') .should('equal', '300000') cy.get('@Paramsform').find('[name="gasLimit"]').parent('div').find('[data-testid="RotateLeftIcon"]').click() - cy.contains('Confirm').click() - // Asserts the execute checkbox is uncheckable - cy.contains('Execute transaction').click() - cy.get('@modal').within(() => { - cy.get('input[type="checkbox"]') - .parent('span') - .should(($div) => { - // Turn the classList into a string - const classListString = Array.from($div[0].classList).join() - // Check if it contains the error class - expect(classListString).not.to.include('checked') - }) - }) - - // If the checkbox is unchecked the relayer is not present - cy.get('@modal').should('not.contain', 'Sponsored by').and('not.contain', 'Transactions per hour') + cy.get('@Paramsform').submit() - cy.contains('Signing the transaction with nonce').should('exist') - - // Changes back to recommended nonce - cy.contains('Signing the transaction with nonce').click() - cy.contains('Edit').click() - cy.get('button[aria-label="Reset to recommended nonce"]').click() - - // Accepts the values - cy.contains('Confirm').click() + // Asserts the execute checkbox is uncheckable + cy.contains('No, only').click() - cy.get('@modal').within(() => { - cy.get('input[type="checkbox"]').should('not.exist') - }) + cy.get('input[name="nonce"]') + .clear({ force: true }) + .type(currentNonce + 10, { force: true }) + .type('{enter}', { force: true }) cy.contains('Submit').click() }) @@ -174,9 +120,9 @@ describe('Queue a transaction on 1/N', () => { cy.contains('h3', 'Transaction details').should('be.visible') // Queue label - cy.contains(`Queued - transaction with nonce ${currentNonce} needs to be executed first`).should('be.visible') + cy.contains(`needs to be executed first`).should('be.visible') // Transaction summary - cy.contains(`${recommendedNonce}` + 'Send' + '-' + `${sendValue} GOR`).should('exist') + cy.contains('Send' + '-' + `${sendValue} GOR`).should('exist') }) }) diff --git a/cypress/e2e/smoke/nfts.cy.js b/cypress/e2e/smoke/nfts.cy.js index 76b6d7fce9..75686930a7 100644 --- a/cypress/e2e/smoke/nfts.cy.js +++ b/cypress/e2e/smoke/nfts.cy.js @@ -72,18 +72,17 @@ describe('Assets > NFTs', () => { // Modal appears cy.contains('Send NFTs') - cy.contains('Sending 2 NFTs from') - cy.contains('Recipient address or ENS *') + cy.contains('Recipient address or ENS') cy.contains('Selected NFTs') cy.get('input[name="recipient"]').type('0x97d314157727D517A706B5D08507A1f9B44AaaE9') cy.contains('button', 'Next').click() // Review modal appears - cy.contains('Review NFT transaction') - cy.contains('Sending 2 NFTs from') + cy.contains('Send') + cy.contains('To') cy.wait(1000) - cy.contains('Action 1') - cy.contains('Action 2') + cy.contains('1') + cy.contains('2') cy.get('b:contains("safeTransferFrom")').should('have.length', 2) cy.contains('button:not([disabled])', 'Submit') }) diff --git a/package.json b/package.json index 94b7f4cc41..bdb49fb43e 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@emotion/server": "^11.10.0", "@emotion/styled": "^11.10.0", "@mui/icons-material": "^5.8.4", - "@mui/material": "^5.11.10", + "@mui/material": "^5.13.5", "@mui/x-date-pickers": "^5.0.12", "@reduxjs/toolkit": "^1.9.5", "@safe-global/safe-apps-sdk": "7.11.0", diff --git a/public/images/common/caret-down.svg b/public/images/common/caret-down.svg new file mode 100644 index 0000000000..4a9f618a15 --- /dev/null +++ b/public/images/common/caret-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/common/check.svg b/public/images/common/check.svg new file mode 100644 index 0000000000..cfe44936f4 --- /dev/null +++ b/public/images/common/check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/common/close.svg b/public/images/common/close.svg index 8bff2f1262..0c4cb574b1 100644 --- a/public/images/common/close.svg +++ b/public/images/common/close.svg @@ -1,5 +1,5 @@ + fill="currentColor" /> \ No newline at end of file diff --git a/public/images/common/minus.svg b/public/images/common/minus.svg new file mode 100644 index 0000000000..b8c1be0708 --- /dev/null +++ b/public/images/common/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/common/plus.svg b/public/images/common/plus.svg new file mode 100644 index 0000000000..c8023ca6ad --- /dev/null +++ b/public/images/common/plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/common/save-address.svg b/public/images/common/save-address.svg new file mode 100644 index 0000000000..d11bfd868d --- /dev/null +++ b/public/images/common/save-address.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/messages/created.svg b/public/images/messages/created.svg index 9dfe24e7da..1aac645c67 100644 --- a/public/images/messages/created.svg +++ b/public/images/messages/created.svg @@ -1,4 +1,4 @@ - + diff --git a/public/images/messages/signed.svg b/public/images/messages/signed.svg index 87f0b434be..3411f0a466 100644 --- a/public/images/messages/signed.svg +++ b/public/images/messages/signed.svg @@ -1,4 +1,4 @@ - + diff --git a/public/images/notifications/info.svg b/public/images/notifications/info.svg index d979abad58..85b63fd090 100644 --- a/public/images/notifications/info.svg +++ b/public/images/notifications/info.svg @@ -1,4 +1,4 @@ - + diff --git a/public/images/sidebar/assets.svg b/public/images/sidebar/assets.svg index 80991d073d..ebcc57514f 100644 --- a/public/images/sidebar/assets.svg +++ b/public/images/sidebar/assets.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/public/images/transactions/new-tx.svg b/public/images/transactions/new-tx.svg new file mode 100644 index 0000000000..51cb5b0be1 --- /dev/null +++ b/public/images/transactions/new-tx.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/transactions/replace-tx.svg b/public/images/transactions/replace-tx.svg new file mode 100644 index 0000000000..b705fd44d4 --- /dev/null +++ b/public/images/transactions/replace-tx.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/transactions/tenderly-dark.svg b/public/images/transactions/tenderly-dark.svg new file mode 100644 index 0000000000..b03291881f --- /dev/null +++ b/public/images/transactions/tenderly-dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/transactions/tenderly-light.svg b/public/images/transactions/tenderly-light.svg new file mode 100644 index 0000000000..59e62bf609 --- /dev/null +++ b/public/images/transactions/tenderly-light.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/components/address-book/AddressBookTable/index.tsx b/src/components/address-book/AddressBookTable/index.tsx index ad38e2f5c5..743f74c806 100644 --- a/src/components/address-book/AddressBookTable/index.tsx +++ b/src/components/address-book/AddressBookTable/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useContext, useMemo, useState } from 'react' import { Box } from '@mui/material' import EnhancedTable from '@/components/common/EnhancedTable' import type { AddressEntry } from '@/components/address-book/EntryDialog' @@ -21,8 +21,8 @@ import PagePlaceholder from '@/components/common/PagePlaceholder' import NoEntriesIcon from '@/public/images/address-book/no-entries.svg' import { useCurrentChain } from '@/hooks/useChains' import tableCss from '@/components/common/EnhancedTable/styles.module.css' -import TokenTransferModal from '@/components/tx/modals/TokenTransferModal' -import { SendAssetsField } from '@/components/tx/modals/TokenTransferModal/SendAssetsForm' +import { TxModalContext } from '@/components/tx-flow' +import TokenTransferFlow from '@/components/tx-flow/flows/TokenTransfer' import CheckWallet from '@/components/common/CheckWallet' const headCells = [ @@ -47,10 +47,11 @@ const defaultOpen = { const AddressBookTable = () => { const chain = useCurrentChain() + const { setTxFlow } = useContext(TxModalContext) + const [open, setOpen] = useState(defaultOpen) const [searchQuery, setSearchQuery] = useState('') const [defaultValues, setDefaultValues] = useState(undefined) - const [selectedAddress, setSelectedAddress] = useState() const handleOpenModal = (type: keyof typeof open) => () => { setOpen((prev) => ({ ...prev, [type]: true })) @@ -117,7 +118,7 @@ const AddressBookTable = () => { variant="contained" color="primary" size="small" - onClick={() => setSelectedAddress(address)} + onClick={() => setTxFlow()} disabled={!isOk} > Send @@ -165,14 +166,6 @@ const AddressBookTable = () => { )} {open[ModalType.REMOVE] && } - - {/* Send funds modal */} - {selectedAddress && ( - setSelectedAddress(undefined)} - initialData={[{ [SendAssetsField.recipient]: selectedAddress }]} - /> - )} ) } diff --git a/src/components/address-book/AddressBookTable/styles.module.css b/src/components/address-book/AddressBookTable/styles.module.css index f1b90b0d0d..fb6af6268a 100644 --- a/src/components/address-book/AddressBookTable/styles.module.css +++ b/src/components/address-book/AddressBookTable/styles.module.css @@ -23,7 +23,7 @@ margin-bottom: 8px; } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .container td:last-of-type button { opacity: 1; } diff --git a/src/components/address-book/EntryDialog/index.tsx b/src/components/address-book/EntryDialog/index.tsx index af9571d0e9..fe63ae7b08 100644 --- a/src/components/address-book/EntryDialog/index.tsx +++ b/src/components/address-book/EntryDialog/index.tsx @@ -1,8 +1,5 @@ -import Box from '@mui/material/Box' -import Button from '@mui/material/Button' -import DialogActions from '@mui/material/DialogActions' -import DialogContent from '@mui/material/DialogContent' -import type { ReactElement } from 'react' +import type { ReactElement, BaseSyntheticEvent } from 'react' +import { Box, Button, DialogActions, DialogContent } from '@mui/material' import { FormProvider, useForm } from 'react-hook-form' import AddressInput from '@/components/common/AddressInput' @@ -41,16 +38,20 @@ const EntryDialog = ({ const { handleSubmit, formState } = methods - const onSubmit = (data: AddressEntry) => { + const submitCallback = handleSubmit((data: AddressEntry) => { dispatch(upsertAddressBookEntry({ ...data, chainId: chainId || currentChainId })) - handleClose() + }) + + const onSubmit = (e: BaseSyntheticEvent) => { + e.stopPropagation() + submitCallback(e) } return ( -
+
diff --git a/src/components/balances/AssetsTable/index.tsx b/src/components/balances/AssetsTable/index.tsx index a88a377bb7..b673142d13 100644 --- a/src/components/balances/AssetsTable/index.tsx +++ b/src/components/balances/AssetsTable/index.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactElement, useMemo } from 'react' +import { type ReactElement, useMemo, useContext } from 'react' import { Button, Tooltip, Typography, SvgIcon, IconButton, Box, Checkbox, Skeleton } from '@mui/material' import type { TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' @@ -8,7 +8,6 @@ import TokenAmount from '@/components/common/TokenAmount' import TokenIcon from '@/components/common/TokenIcon' import EnhancedTable, { type EnhancedTableProps } from '@/components/common/EnhancedTable' import TokenExplorerLink from '@/components/common/TokenExplorerLink' -import TokenTransferModal from '@/components/tx/modals/TokenTransferModal' import Track from '@/components/common/Track' import { ASSETS_EVENTS } from '@/services/analytics/events/assets' import InfoIcon from '@/public/images/notifications/info.svg' @@ -19,6 +18,8 @@ import useHiddenTokens from '@/hooks/useHiddenTokens' import { useHideAssets } from './useHideAssets' import CheckWallet from '@/components/common/CheckWallet' import useSpendingLimit from '@/hooks/useSpendingLimit' +import { TxModalContext } from '@/components/tx-flow' +import TokenTransferFlow from '@/components/tx-flow/flows/TokenTransfer' const skeletonCells: EnhancedTableProps['rows'][0]['cells'] = { asset: { @@ -120,9 +121,9 @@ const AssetsTable = ({ showHiddenAssets: boolean setShowHiddenAssets: (hidden: boolean) => void }): ReactElement => { - const [selectedAsset, setSelectedAsset] = useState() const hiddenAssets = useHiddenTokens() const { balances, loading } = useBalances() + const { setTxFlow } = useContext(TxModalContext) const { isAssetSelected, toggleAsset, hidingAsset, hideAsset, cancel, deselectAll, saveChanges } = useHideAssets(() => setShowHiddenAssets(false), @@ -138,6 +139,10 @@ const AssetsTable = ({ const selectedAssetCount = visibleAssets?.filter((item) => isAssetSelected(item.tokenInfo.address)).length || 0 + const onSendClick = (tokenAddress: string) => { + setTxFlow() + } + const rows = loading ? skeletonRows : (visibleAssets || []).map((item) => { @@ -207,7 +212,7 @@ const AssetsTable = ({ content: ( <> - + onSendClick(item.tokenInfo.address)} /> {showHiddenAssets ? ( toggleAsset(item.tokenInfo.address)} /> @@ -244,12 +249,6 @@ const AssetsTable = ({
- {selectedAsset && ( - setSelectedAsset(undefined)} - initialData={[{ tokenAddress: selectedAsset }]} - /> - )}
) diff --git a/src/components/balances/AssetsTable/styles.module.css b/src/components/balances/AssetsTable/styles.module.css index b9b5882cd5..55c14fbbe9 100644 --- a/src/components/balances/AssetsTable/styles.module.css +++ b/src/components/balances/AssetsTable/styles.module.css @@ -14,7 +14,7 @@ gap: var(--space-1); } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .container td:last-of-type, .container th:last-of-type { display: none; diff --git a/src/components/balances/HiddenTokenButton/styles.module.css b/src/components/balances/HiddenTokenButton/styles.module.css index fddd67f640..ce335e2648 100644 --- a/src/components/balances/HiddenTokenButton/styles.module.css +++ b/src/components/balances/HiddenTokenButton/styles.module.css @@ -1,4 +1,4 @@ -@media (max-width: 600px) { +@media (max-width: 599.95px) { .hiddenTokenButton { display: none; } diff --git a/src/components/common/AddressBookInput/index.tsx b/src/components/common/AddressBookInput/index.tsx index dff6a1261e..eaed6a892d 100644 --- a/src/components/common/AddressBookInput/index.tsx +++ b/src/components/common/AddressBookInput/index.tsx @@ -1,10 +1,14 @@ -import type { ReactElement } from 'react' +import { type ReactElement, useState, useMemo } from 'react' import { useFormContext, useWatch } from 'react-hook-form' -import { Typography } from '@mui/material' +import { SvgIcon, Typography } from '@mui/material' import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete' import useAddressBook from '@/hooks/useAddressBook' import AddressInput, { type AddressInputProps } from '../AddressInput' import EthHashInfo from '../EthHashInfo' +import InfoIcon from '@/public/images/notifications/info.svg' +import EntryDialog from '@/components/address-book/EntryDialog' +import css from './styles.module.css' +import inputCss from '@/styles/inputs.module.css' const abFilterOptions = createFilterOptions({ stringify: (option: { label: string; name: string }) => option.name + ' ' + option.label, @@ -13,37 +17,89 @@ const abFilterOptions = createFilterOptions({ /** * Temporary component until revamped safe components are done */ -const AddressBookInput = ({ name, ...props }: AddressInputProps): ReactElement => { +const AddressBookInput = ({ name, canAdd, ...props }: AddressInputProps & { canAdd?: boolean }): ReactElement => { const addressBook = useAddressBook() const { setValue, control } = useFormContext() const addressValue = useWatch({ name, control }) + const [open, setOpen] = useState(false) + const [openAddressBook, setOpenAddressBook] = useState(false) const addressBookEntries = Object.entries(addressBook).map(([address, name]) => ({ label: address, name, })) + const hasVisibleOptions = useMemo( + () => !!addressBookEntries.filter((entry) => entry.label.includes(addressValue)).length, + [addressBookEntries, addressValue], + ) + + const handleOpenAutocomplete = () => { + setOpen((value) => !value) + } + + const onAddressBookClick = canAdd + ? () => { + setOpenAddressBook(true) + } + : undefined + return ( - setValue(name, value, { shouldValidate: true })} - filterOptions={abFilterOptions} - componentsProps={{ - paper: { - elevation: 2, - }, - }} - renderOption={(props, option) => ( - - + <> + setOpen(true)} + onClose={() => setOpen(false)} + className={inputCss.input} + disableClearable + value={addressValue || ''} + disabled={props.disabled} + readOnly={props.InputProps?.readOnly} + freeSolo + options={addressBookEntries} + onInputChange={(_, value) => setValue(name, value, { shouldValidate: true })} + filterOptions={abFilterOptions} + componentsProps={{ + paper: { + elevation: 2, + }, + }} + renderOption={(props, option) => ( + + + + )} + renderInput={(params) => ( + + )} + /> + {canAdd ? ( + + + + This is an unknown address. You can{' '} + + add it to your address book + + . + + ) : null} + + {openAddressBook && ( + setOpenAddressBook(false)} + defaultValues={{ name: '', address: addressValue }} + /> )} - renderInput={(params) => } - /> + ) } diff --git a/src/components/common/AddressBookInput/styles.module.css b/src/components/common/AddressBookInput/styles.module.css index e69de29bb2..c1366746d2 100644 --- a/src/components/common/AddressBookInput/styles.module.css +++ b/src/components/common/AddressBookInput/styles.module.css @@ -0,0 +1,20 @@ +.unknownAddress { + margin-top: calc(-1 * var(--space-2)); + padding: 20px 12px 4px; + background-color: var(--color-background-main); + color: var(--color-text-secondary); + display: flex; + gap: var(--space-1); + width: 100%; + border-radius: 6px; +} + +.unknownAddress svg { + height: auto; +} + +.unknownAddress a { + color: inherit; + text-decoration: underline; + cursor: pointer; +} diff --git a/src/components/common/AddressInput/index.test.tsx b/src/components/common/AddressInput/index.test.tsx index 1480cbc037..4a87f46d5d 100644 --- a/src/components/common/AddressInput/index.test.tsx +++ b/src/components/common/AddressInput/index.test.tsx @@ -194,7 +194,7 @@ describe('AddressInput tests', () => { expect(input.previousElementSibling?.textContent).toBe('gor:') }) - it('should not show adornment when the value contains correct prefix', async () => { + it('should not show the adornment prefix when the value contains correct prefix', async () => { ;(useCurrentChain as jest.Mock).mockImplementation(() => ({ shortName: 'gor', chainId: '5', @@ -208,7 +208,7 @@ describe('AddressInput tests', () => { fireEvent.change(input, { target: { value: `gor:${TEST_ADDRESS_B}` } }) }) - await waitFor(() => expect(input.previousElementSibling).toBe(null)) + await waitFor(() => expect(input.previousElementSibling?.textContent).toBe('')) }) it('should keep a bare address in the form state', async () => { diff --git a/src/components/common/AddressInput/index.tsx b/src/components/common/AddressInput/index.tsx index 8d281be501..82ba6d2d2b 100644 --- a/src/components/common/AddressInput/index.tsx +++ b/src/components/common/AddressInput/index.tsx @@ -1,6 +1,14 @@ import type { ReactElement } from 'react' import { useEffect, useCallback, useRef, useMemo, useState } from 'react' -import { InputAdornment, TextField, type TextFieldProps, CircularProgress, Grid } from '@mui/material' +import { + InputAdornment, + TextField, + type TextFieldProps, + CircularProgress, + IconButton, + SvgIcon, + Skeleton, +} from '@mui/material' import { useFormContext, useWatch, type Validate, get } from 'react-hook-form' import { validatePrefixedAddress } from '@/utils/validation' import { useCurrentChain } from '@/hooks/useChains' @@ -9,10 +17,32 @@ import ScanQRButton from '../ScanQRModal/ScanQRButton' import { FEATURES, hasFeature } from '@/utils/chains' import { cleanInputValue, parsePrefixedAddress } from '@/utils/addresses' import useDebounce from '@/hooks/useDebounce' +import CaretDownIcon from '@/public/images/common/caret-down.svg' +import SaveAddressIcon from '@/public/images/common/save-address.svg' +import classnames from 'classnames' +import css from './styles.module.css' +import inputCss from '@/styles/inputs.module.css' + +export type AddressInputProps = TextFieldProps & { + name: string + address?: string + onOpenListClick?: () => void + isAutocompleteOpen?: boolean + validate?: Validate + deps?: string | string[] + onAddressBookClick?: () => void +} -export type AddressInputProps = TextFieldProps & { name: string; validate?: Validate; deps?: string | string[] } - -const AddressInput = ({ name, validate, required = true, deps, ...props }: AddressInputProps): ReactElement => { +const AddressInput = ({ + name, + validate, + required = true, + onOpenListClick, + isAutocompleteOpen, + onAddressBookClick, + deps, + ...props +}: AddressInputProps): ReactElement => { const { register, setValue, @@ -28,7 +58,6 @@ const AddressInput = ({ name, validate, required = true, deps, ...props }: Addre // Fetch an ENS resolution for the current address const isDomainLookupEnabled = !!currentChain && hasFeature(currentChain, FEATURES.DOMAIN_LOOKUP) - const label = `${props.label} address${isDomainLookupEnabled ? ' or ENS' : ''}` const { address, resolverError, resolving } = useNameResolver(isDomainLookupEnabled ? watchedValue : '') // errors[name] doesn't work with nested field names like 'safe.address', need to use the lodash get @@ -55,73 +84,93 @@ const AddressInput = ({ name, validate, required = true, deps, ...props }: Addre } }, [address, currentShortName, setAddressValue]) - return ( - - - {error?.message || label}} - error={!!error} - fullWidth - spellCheck={false} - InputProps={{ - ...(props.InputProps || {}), - - // Display the current short name in the adornment, unless the value contains the same prefix - startAdornment: !error && !rawValueRef.current.startsWith(`${currentShortName}:`) && ( - {currentShortName}: - ), - - endAdornment: (resolving || isValidating) && ( - - - - ), - }} - InputLabelProps={{ - ...(props.InputLabelProps || {}), - shrink: !!watchedValue || props.focused, - }} - required={required} - {...register(name, { - deps, - - required, - - setValueAs: (value: string): string => { - // Clean the input value - const cleanValue = cleanInputValue(value) - rawValueRef.current = cleanValue - // This also checksums the address - return parsePrefixedAddress(cleanValue).address - }, - - validate: async () => { - const value = rawValueRef.current - if (value) { - setIsValidating(true) - const result = validatePrefixed(value) || (await validate?.(parsePrefixedAddress(value).address)) - setIsValidating(false) - return result - } - }, - - // Workaround for a bug in react-hook-form that it restores a cached error state on blur - onBlur: () => setTimeout(() => trigger(name), 100), - })} - // Workaround for a bug in react-hook-form when `register().value` is cached after `setValueAs` - // Only seems to occur on the `/load` route - value={watchedValue} - /> - - - {!props.disabled && ( - + const endAdornment = ( + + {resolving || isValidating ? ( + + ) : !props.disabled ? ( + <> + {onAddressBookClick && ( + + + + )} + - - )} - + + {onOpenListClick && ( + + + + )} + + ) : null} + + ) + + return ( + <> + {error?.message || props.label}} + error={!!error} + fullWidth + spellCheck={false} + InputProps={{ + ...(props.InputProps || {}), + + // Display the current short name in the adornment, unless the value contains the same prefix + startAdornment: ( + + + {!rawValueRef.current.startsWith(`${currentShortName}:`) && <>{currentShortName}:} + + ), + + endAdornment, + }} + InputLabelProps={{ + ...(props.InputLabelProps || {}), + shrink: true, + }} + {...register(name, { + deps, + + required, + + setValueAs: (value: string): string => { + // Clean the input value + const cleanValue = cleanInputValue(value) + rawValueRef.current = cleanValue + // This also checksums the address + return parsePrefixedAddress(cleanValue).address + }, + + validate: async () => { + const value = rawValueRef.current + if (value) { + setIsValidating(true) + const result = validatePrefixed(value) || (await validate?.(parsePrefixedAddress(value).address)) + setIsValidating(false) + return result + } + }, + + // Workaround for a bug in react-hook-form that it restores a cached error state on blur + onBlur: () => setTimeout(() => trigger(name), 100), + })} + // Workaround for a bug in react-hook-form when `register().value` is cached after `setValueAs` + // Only seems to occur on the `/load` route + value={watchedValue} + /> + ) } diff --git a/src/components/common/AddressInput/styles.module.css b/src/components/common/AddressInput/styles.module.css new file mode 100644 index 0000000000..f5264e5ba8 --- /dev/null +++ b/src/components/common/AddressInput/styles.module.css @@ -0,0 +1,15 @@ +.wrapper :global .MuiInputLabel-root.Mui-error[data-shrink='false'] { + padding: 5px 4px; +} + +.wrapper :global .MuiInputAdornment-root { + margin-left: 0; +} + +.openButton svg { + transition: transform 0.3s ease-in-out; +} + +.rotated svg { + transform: rotate(180deg); +} diff --git a/src/components/common/AddressInputReadOnly/index.tsx b/src/components/common/AddressInputReadOnly/index.tsx new file mode 100644 index 0000000000..ac9325c6c2 --- /dev/null +++ b/src/components/common/AddressInputReadOnly/index.tsx @@ -0,0 +1,44 @@ +import { useState, type ReactElement } from 'react' +import { IconButton, InputAdornment, InputLabel, OutlinedInput, SvgIcon, Typography } from '@mui/material' +import EthHashInfo from '@/components/common/EthHashInfo' +import SaveAddressIcon from '@/public/images/common/save-address.svg' +import css from './styles.module.css' +import EntryDialog from '@/components/address-book/EntryDialog' +import useAddressBook from '@/hooks/useAddressBook' + +const AddressInputReadOnly = ({ label, address }: { label: string; address: string }): ReactElement => { + const addressBook = useAddressBook() + const [open, setOpen] = useState(false) + + return ( + <> +
+ {label} + + + + + + } + endAdornment={ + !addressBook[address] ? ( + + setOpen(true)}> + + + + ) : null + } + label={label} + readOnly + /> +
+ {open && setOpen(false)} defaultValues={{ name: '', address }} />} + + ) +} + +export default AddressInputReadOnly diff --git a/src/components/common/AddressInputReadOnly/styles.module.css b/src/components/common/AddressInputReadOnly/styles.module.css new file mode 100644 index 0000000000..9a02cc7eb6 --- /dev/null +++ b/src/components/common/AddressInputReadOnly/styles.module.css @@ -0,0 +1,40 @@ +.wrapper :global .MuiInputBase-root:hover .MuiOutlinedInput-notchedOutline, +.wrapper :global .MuiInputBase-root .MuiOutlinedInput-notchedOutline { + border-color: var(--color-text-secondary); + border-width: 1px; +} + +.wrapper :global .MuiInputLabel-root { + color: var(--color-text-secondary); +} + +.wrapper :global .MuiInputBase-root { + height: 66px; +} + +.wrapper :global input[text] { + padding: 0; +} + +.input { + width: 100%; + padding: 12px var(--space-2); +} + +.input :global .MuiInputBase-input { + padding: var(--space-1) var(--space-2); +} + +.input input[type='text'] { + padding-left: 0; + padding-right: 0; +} + +.input [title] { + font-weight: bold; + color: var(--color-text-primary); +} + +.input fieldset { + border-color: var(--color-border-light) !important; +} diff --git a/src/components/common/ConnectWallet/styles.module.css b/src/components/common/ConnectWallet/styles.module.css index 592eb7841f..d988b29eec 100644 --- a/src/components/common/ConnectWallet/styles.module.css +++ b/src/components/common/ConnectWallet/styles.module.css @@ -54,7 +54,7 @@ gap: var(--space-2); } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .buttonContainer button { font-size: 12px; } diff --git a/src/components/common/CookieBanner/styles.module.css b/src/components/common/CookieBanner/styles.module.css index 0dafa4fc80..fb2777108e 100644 --- a/src/components/common/CookieBanner/styles.module.css +++ b/src/components/common/CookieBanner/styles.module.css @@ -16,7 +16,7 @@ user-select: none; } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .container { right: 0; bottom: 0; diff --git a/src/components/common/DatePickerInput/index.tsx b/src/components/common/DatePickerInput/index.tsx index 08c9617d36..ed760e712e 100644 --- a/src/components/common/DatePickerInput/index.tsx +++ b/src/components/common/DatePickerInput/index.tsx @@ -5,6 +5,8 @@ import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns' import TextField from '@mui/material/TextField' import { isFuture, isValid, startOfDay } from 'date-fns' +import inputCss from '@/styles/inputs.module.css' + const DatePickerInput = ({ name, label, @@ -46,6 +48,7 @@ const DatePickerInput = ({ render={({ field, fieldState }) => ( ) diff --git a/src/components/common/PageHeader/styles.module.css b/src/components/common/PageHeader/styles.module.css index 73bc9914c6..e4f79953a2 100644 --- a/src/components/common/PageHeader/styles.module.css +++ b/src/components/common/PageHeader/styles.module.css @@ -30,7 +30,7 @@ gap: var(--space-1); } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .container { padding: var(--space-3) var(--space-2) 0; } diff --git a/src/components/common/PageLayout/index.tsx b/src/components/common/PageLayout/index.tsx index a762dc6abb..a7e25b6531 100644 --- a/src/components/common/PageLayout/index.tsx +++ b/src/components/common/PageLayout/index.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState, type ReactElement } from 'react' +import { useContext, useEffect, useState, type ReactElement } from 'react' import classnames from 'classnames' -import Header from '@/components/common//Header' +import Header from '@/components/common/Header' import css from './styles.module.css' import SafeLoadingError from '../SafeLoadingError' import Footer from '../Footer' @@ -9,6 +9,7 @@ import SideDrawer from './SideDrawer' import { AppRoutes } from '@/config/routes' import useDebounce from '@/hooks/useDebounce' import { useRouter } from 'next/router' +import { TxModalContext } from '@/components/tx-flow' const isNoSidebarRoute = (pathname: string): boolean => { return [ @@ -29,6 +30,8 @@ const PageLayout = ({ pathname, children }: { pathname: string; children: ReactE const router = useRouter() const [noSidebar, setNoSidebar] = useState(isNoSidebarRoute(pathname)) const [isSidebarOpen, setSidebarOpen] = useState(true) + const hideSidebar = noSidebar || !isSidebarOpen + const { setFullWidth } = useContext(TxModalContext) let isAnimated = useDebounce(!noSidebar, 300) if (noSidebar) isAnimated = false @@ -37,6 +40,10 @@ const PageLayout = ({ pathname, children }: { pathname: string; children: ReactE setNoSidebar(isNoSidebarRoute(pathname) || noSafeAddress) }, [pathname, router]) + useEffect(() => { + setFullWidth(hideSidebar) + }, [hideSidebar, setFullWidth]) + return ( <>
@@ -47,7 +54,7 @@ const PageLayout = ({ pathname, children }: { pathname: string; children: ReactE
diff --git a/src/components/common/PageLayout/styles.module.css b/src/components/common/PageLayout/styles.module.css index 2f8f578ea0..c4dff8197e 100644 --- a/src/components/common/PageLayout/styles.module.css +++ b/src/components/common/PageLayout/styles.module.css @@ -37,7 +37,7 @@ .sidebarTogglePosition { position: fixed; - z-index: 2; + z-index: 4; left: 0; top: 0; /* mimics MUI drawer animation */ @@ -72,7 +72,7 @@ background-color: var(--color-background-light); } -@media (max-width: 900px) { +@media (max-width: 899.95px) { .main { padding-left: 0; } @@ -82,7 +82,7 @@ } } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .main main { padding: var(--space-2); } diff --git a/src/components/common/ProgressBar/index.tsx b/src/components/common/ProgressBar/index.tsx new file mode 100644 index 0000000000..4faa499c0e --- /dev/null +++ b/src/components/common/ProgressBar/index.tsx @@ -0,0 +1,8 @@ +import { LinearProgress } from '@mui/material' +import type { LinearProgressProps } from '@mui/material' + +import css from './styles.module.css' + +export const ProgressBar = (props: LinearProgressProps) => { + return +} diff --git a/src/components/common/ProgressBar/styles.module.css b/src/components/common/ProgressBar/styles.module.css new file mode 100644 index 0000000000..bdc2c6a2eb --- /dev/null +++ b/src/components/common/ProgressBar/styles.module.css @@ -0,0 +1,16 @@ +.progressBar { + height: 6px; + border-radius: 6px; + background-color: var(--color-border-light); +} + +.progressBar :global .MuiLinearProgress-bar { + background: var(--color-primary-main); + border-radius: 6px; +} + +@media (max-width: 599.95px) { + .progressBar { + border-radius: 0; + } +} diff --git a/src/components/common/QRCode/index.tsx b/src/components/common/QRCode/index.tsx index db4058252a..533ecdcf02 100644 --- a/src/components/common/QRCode/index.tsx +++ b/src/components/common/QRCode/index.tsx @@ -1,6 +1,6 @@ import QRCodeReact from 'qrcode.react' import { Skeleton } from '@mui/material' -import { useTheme } from '@mui/system' +import { useTheme } from '@mui/material/styles' import type { ReactElement } from 'react' const QR_LOGO_SIZE = 20 diff --git a/src/components/common/SpendingLimitLabel/index.tsx b/src/components/common/SpendingLimitLabel/index.tsx index a96c48a29e..eecaba68b7 100644 --- a/src/components/common/SpendingLimitLabel/index.tsx +++ b/src/components/common/SpendingLimitLabel/index.tsx @@ -10,8 +10,8 @@ const SpendingLimitLabel = ({ }: { label: string | ReactElement; isOneTime?: boolean } & BoxProps) => { return ( - {!isOneTime && } - {typeof label === 'string' ? {label} : label} + {!isOneTime && } + {typeof label === 'string' ? {label} : label} ) } diff --git a/src/components/common/Sticky/index.tsx b/src/components/common/Sticky/index.tsx index 108de0956c..7bc390b4ca 100644 --- a/src/components/common/Sticky/index.tsx +++ b/src/components/common/Sticky/index.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from 'react' const stickyTop = { xs: '103px', md: '111px' } export const Sticky = ({ children }: { children: ReactElement }): ReactElement => ( - + {children} ) diff --git a/src/components/common/TokenAmountInput/index.tsx b/src/components/common/TokenAmountInput/index.tsx new file mode 100644 index 0000000000..f1c81e9fdb --- /dev/null +++ b/src/components/common/TokenAmountInput/index.tsx @@ -0,0 +1,100 @@ +import { Button, Divider, FormControl, InputLabel, MenuItem, TextField } from '@mui/material' +import { type SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' +import css from './styles.module.css' +import NumberField from '@/components/common/NumberField' +import { validateDecimalLength, validateLimitedAmount } from '@/utils/validation' +import { AutocompleteItem } from '@/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer' +import { useFormContext } from 'react-hook-form' +import { type BigNumber } from '@ethersproject/bignumber' +import classNames from 'classnames' +import { useCallback } from 'react' + +export enum TokenAmountFields { + tokenAddress = 'tokenAddress', + amount = 'amount', +} + +const TokenAmountInput = ({ + balances, + selectedToken, + onMaxAmountClick, + maxAmount, + validate, +}: { + balances: SafeBalanceResponse['items'] + selectedToken: SafeBalanceResponse['items'][number] | undefined + onMaxAmountClick?: () => void + maxAmount?: BigNumber + validate?: (value: string) => string | undefined +}) => { + const { + formState: { errors }, + register, + resetField, + watch, + } = useFormContext<{ [TokenAmountFields.tokenAddress]: string; [TokenAmountFields.amount]: string }>() + + const tokenAddress = watch(TokenAmountFields.tokenAddress) + const isAmountError = !!errors[TokenAmountFields.tokenAddress] || !!errors[TokenAmountFields.amount] + + const validateAmount = useCallback( + (value: string) => { + const decimals = selectedToken?.tokenInfo.decimals + return validateLimitedAmount(value, decimals, maxAmount?.toString()) || validateDecimalLength(value, decimals) + }, + [maxAmount, selectedToken?.tokenInfo.decimals], + ) + + return ( + + + {errors[TokenAmountFields.tokenAddress]?.message || errors[TokenAmountFields.amount]?.message || 'Amount'} + +
+ + Max + + ), + }} + className={css.amount} + required + placeholder="0" + {...register(TokenAmountFields.amount, { + required: true, + validate: validate ?? validateAmount, + })} + /> + + { + resetField(TokenAmountFields.amount, { defaultValue: '' }) + }, + })} + value={tokenAddress} + required + > + {balances.map((item) => ( + + + + ))} + +
+
+ ) +} + +export default TokenAmountInput diff --git a/src/components/common/TokenAmountInput/styles.module.css b/src/components/common/TokenAmountInput/styles.module.css new file mode 100644 index 0000000000..35f9d65aa3 --- /dev/null +++ b/src/components/common/TokenAmountInput/styles.module.css @@ -0,0 +1,59 @@ +.outline { + border: 1px solid var(--color-border-light); + border-radius: 6px; +} + +.error { + border-color: var(--color-error-main); +} + +.error :global(.MuiFormLabel-root) { + color: var(--color-error-main); +} + +.label { + background-color: var(--color-background-paper); + padding-left: 6px; + padding-right: 6px; + margin-left: -6px; +} + +.inputs { + display: inline-flex; + align-items: center; +} + +.amount { + min-width: 130px; + flex-grow: 1; +} + +.amount :global(.MuiInput-input) { + padding-left: var(--space-2); +} + +.max { + text-transform: uppercase; + background-color: var(--color-background-main); + color: var(--color-text-primary); + font-size: 12px; + margin-right: var(--space-1); + min-height: 50px; + padding: var(--space-2); +} + +.select { + flex-shrink: 0; +} + +.select :global(.MuiSelect-select) { + margin: var(--space-1); + display: flex; + background-color: var(--color-background-main); + border-radius: 6px; + padding: var(--space-1) var(--space-5) var(--space-1) var(--space-2) !important; +} + +.select :global(.MuiSelect-icon) { + margin-right: var(--space-2); +} diff --git a/src/components/common/TxModalDialog/index.tsx b/src/components/common/TxModalDialog/index.tsx new file mode 100644 index 0000000000..904c15175c --- /dev/null +++ b/src/components/common/TxModalDialog/index.tsx @@ -0,0 +1,51 @@ +import { type ReactElement } from 'react' +import { IconButton, Dialog, DialogTitle, type DialogProps } from '@mui/material' +import classnames from 'classnames' +import CloseIcon from '@mui/icons-material/Close' +import css from './styles.module.css' + +interface ModalDialogProps extends DialogProps { + dialogTitle?: React.ReactNode + hideChainIndicator?: boolean +} + +const TxModalDialog = ({ + dialogTitle, + hideChainIndicator, + children, + onClose, + fullScreen = false, + fullWidth = false, + ...restProps +}: ModalDialogProps): ReactElement => { + return ( + e.stopPropagation()} + hideBackdrop + PaperProps={{ + className: css.paper, + }} + > + +
+ onClose?.(e, 'backdropClick')} + size="small" + > + + +
+
+ + {children} +
+ ) +} + +export default TxModalDialog diff --git a/src/components/common/TxModalDialog/styles.module.css b/src/components/common/TxModalDialog/styles.module.css new file mode 100644 index 0000000000..30ad5e0157 --- /dev/null +++ b/src/components/common/TxModalDialog/styles.module.css @@ -0,0 +1,82 @@ +.dialog { + top: 52px; + left: 230px; + z-index: 3; + transition: left 225ms cubic-bezier(0, 0, 0.2, 1) 0ms; +} + +.dialog.fullWidth { + left: 0; +} + +.dialog :global .MuiDialogActions-root { + border-top: 2px solid var(--color-border-light); + padding: var(--space-3); +} + +.dialog :global .MuiDialogActions-root > :last-of-type:not(:first-of-type) { + order: 2; +} + +.dialog :global .MuiDialogActions-root:after { + content: ''; + order: 1; + flex: 1; +} + +.title { + display: flex; + align-items: center; + padding: 0; +} + +.buttons { + margin-left: auto; + padding: var(--space-1); +} + +.close { + color: var(--color-border-main); + padding: var(--space-1); +} + +.paper { + padding-bottom: var(--space-8); + background-color: var(--color-border-background); +} + +@media (min-width: 600px) { + .dialog :global .MuiDialog-paper { + min-width: 600px; + margin: 0; + } +} + +@media (min-width: 900px) { + .title { + position: sticky; + top: 0; + z-index: 1; + } +} + +@media (max-width: 899.95px) { + .dialog { + left: 0; + top: 0; + z-index: 1300; + } + + .dialog :global .MuiDialogActions-root { + padding: 0; + } + + .title { + margin-bottom: var(--space-3); + background-color: var(--color-background-paper); + } + + .close svg { + font-size: 1.5rem; + } +} diff --git a/src/components/common/WalletInfo/styles.module.css b/src/components/common/WalletInfo/styles.module.css index 4b22ce8f66..6bc2e53d07 100644 --- a/src/components/common/WalletInfo/styles.module.css +++ b/src/components/common/WalletInfo/styles.module.css @@ -15,7 +15,7 @@ filter: invert(100%); } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .buttonContainer button { font-size: 12px; } diff --git a/src/components/dashboard/PendingTxs/styles.module.css b/src/components/dashboard/PendingTxs/styles.module.css index 42326042e4..f5dc2103d5 100644 --- a/src/components/dashboard/PendingTxs/styles.module.css +++ b/src/components/dashboard/PendingTxs/styles.module.css @@ -37,7 +37,7 @@ word-break: break-word; } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .confirmationsCount { padding: 4px var(--space-1); } diff --git a/src/components/new-safe/CardStepper/styles.module.css b/src/components/new-safe/CardStepper/styles.module.css index c7cdb6ec38..53cef51b2a 100644 --- a/src/components/new-safe/CardStepper/styles.module.css +++ b/src/components/new-safe/CardStepper/styles.module.css @@ -33,7 +33,7 @@ display: none; } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .header { padding: var(--space-2); flex-direction: column; diff --git a/src/components/new-safe/OwnerRow/index.tsx b/src/components/new-safe/OwnerRow/index.tsx index a97dadb770..d85b4ec8f2 100644 --- a/src/components/new-safe/OwnerRow/index.tsx +++ b/src/components/new-safe/OwnerRow/index.tsx @@ -78,7 +78,6 @@ export const OwnerRow = ({ {!readOnly && ( - + {removable && ( <> remove?.(index)} aria-label="Remove owner"> diff --git a/src/components/new-safe/OwnerRow/styles.module.css b/src/components/new-safe/OwnerRow/styles.module.css index 3369f41056..32d2f42aa0 100644 --- a/src/components/new-safe/OwnerRow/styles.module.css +++ b/src/components/new-safe/OwnerRow/styles.module.css @@ -1,15 +1,3 @@ -.name :global .MuiFormHelperText-root { - position: absolute; - bottom: -20px; -} - -@media (max-width: 900px) { - .name :global .MuiFormHelperText-root { - position: relative; - bottom: 0; - } -} - @media (min-width: 900px) { .helper { margin-bottom: var(--space-5); diff --git a/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx b/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx index e15941f2d9..81ee517a65 100644 --- a/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx +++ b/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx @@ -1,4 +1,4 @@ -import { Button, SvgIcon, MenuItem, Select, Tooltip, Typography, Divider, Box } from '@mui/material' +import { Button, SvgIcon, MenuItem, Tooltip, Typography, Divider, Box, Grid, TextField } from '@mui/material' import { Controller, FormProvider, useFieldArray, useForm } from 'react-hook-form' import type { ReactElement } from 'react' @@ -11,7 +11,6 @@ import type { CreateSafeInfoItem } from '@/components/new-safe/create/CreateSafe import { useSafeSetupHints } from '@/components/new-safe/create/steps/OwnerPolicyStep/useSafeSetupHints' import useSyncSafeCreationStep from '@/components/new-safe/create/useSyncSafeCreationStep' import ArrowBackIcon from '@mui/icons-material/ArrowBack' -import css from '@/components/new-safe/create/steps/OwnerPolicyStep/styles.module.css' import layoutCss from '@/components/new-safe/create/styles.module.css' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' import useIsWrongChain from '@/hooks/useIsWrongChain' @@ -145,22 +144,26 @@ const OwnerPolicyStep = ({ Any transaction requires the confirmation of: - - ( - - )} - />{' '} - out of {ownerFields.length} owner(s). - + + + ( + + {ownerFields.map((_, idx) => ( + + {idx + 1} + + ))} + + )} + /> + + + out of {ownerFields.length} owner(s) + + {isWrongChain && }
diff --git a/src/components/new-safe/create/steps/OwnerPolicyStep/styles.module.css b/src/components/new-safe/create/steps/OwnerPolicyStep/styles.module.css deleted file mode 100644 index c4083eb1ab..0000000000 --- a/src/components/new-safe/create/steps/OwnerPolicyStep/styles.module.css +++ /dev/null @@ -1,12 +0,0 @@ -.select { - margin-right: var(--space-1); -} - -.select :global .MuiOutlinedInput-notchedOutline { - border-color: var(--color-border-light); - border-width: 2px; -} - -.select :global .MuiSelect-select { - padding: 12px 14px; -} diff --git a/src/components/new-safe/create/steps/SetNameStep/styles.module.css b/src/components/new-safe/create/steps/SetNameStep/styles.module.css index e273a1c87e..6a8ce8b855 100644 --- a/src/components/new-safe/create/steps/SetNameStep/styles.module.css +++ b/src/components/new-safe/create/steps/SetNameStep/styles.module.css @@ -7,7 +7,7 @@ align-items: center; border-radius: 8px; border: 1px solid var(--color-border-light); - height: 56px; + height: 66px; } .select:hover, diff --git a/src/components/new-safe/create/steps/StatusStep/LoadingSpinner/index.tsx b/src/components/new-safe/create/steps/StatusStep/LoadingSpinner/index.tsx index 601c055c62..ad15b27a62 100644 --- a/src/components/new-safe/create/steps/StatusStep/LoadingSpinner/index.tsx +++ b/src/components/new-safe/create/steps/StatusStep/LoadingSpinner/index.tsx @@ -1,7 +1,6 @@ import { Box } from '@mui/material' import css from '@/components/new-safe/create/steps/StatusStep/LoadingSpinner/styles.module.css' import classnames from 'classnames' -import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' import { useCallback, useEffect, useRef } from 'react' const rectTlEndTransform = 'translateX(0) translateY(20px) scaleY(1.1)' @@ -30,9 +29,16 @@ const moveToEnd = (transformEnd: string, element: HTMLDivElement | null) => { } } -const LoadingSpinner = ({ status }: { status: SafeCreationStatus }) => { - const isError = status >= SafeCreationStatus.WALLET_REJECTED && status <= SafeCreationStatus.TIMEOUT - const isSuccess = status >= SafeCreationStatus.SUCCESS +export enum SpinnerStatus { + ERROR = 'isError', + SUCCESS = 'isSuccess', + PROCESSING = 'isProcessing', +} + +const LoadingSpinner = ({ status }: { status: SpinnerStatus }) => { + // TODO: only monitoring the PendingTxs we can't determine the transaction's result + const isError = status === SpinnerStatus.ERROR + const isSuccess = status === SpinnerStatus.SUCCESS const rectTl = useRef(null) const rectTr = useRef(null) diff --git a/src/components/new-safe/create/steps/StatusStep/LoadingSpinner/styles.module.css b/src/components/new-safe/create/steps/StatusStep/LoadingSpinner/styles.module.css index 4dcb5c0b32..2ca4ef0c06 100644 --- a/src/components/new-safe/create/steps/StatusStep/LoadingSpinner/styles.module.css +++ b/src/components/new-safe/create/steps/StatusStep/LoadingSpinner/styles.module.css @@ -11,7 +11,8 @@ animation-play-state: paused; } -.rectSuccess .rectCenter { +.rectSuccess .rectCenter, +.rectError .rectCenter { visibility: visible; transform: translateY(30px) translateX(30px) scale(1); } diff --git a/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx b/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx index b385c299ef..76afa78314 100644 --- a/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx +++ b/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx @@ -1,6 +1,6 @@ import { Box, Typography } from '@mui/material' import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' -import LoadingSpinner from '@/components/new-safe/create/steps/StatusStep/LoadingSpinner' +import LoadingSpinner, { SpinnerStatus } from '@/components/new-safe/create/steps/StatusStep/LoadingSpinner' const getStep = (status: SafeCreationStatus) => { const ERROR_TEXT = 'Please cancel the process or retry the transaction.' @@ -59,11 +59,13 @@ const StatusMessage = ({ status, isError }: { status: SafeCreationStatus; isErro const stepInfo = getStep(status) const color = isError ? 'error' : 'info' + const isSuccess = status >= SafeCreationStatus.SUCCESS + const spinnerStatus = isError ? SpinnerStatus.ERROR : isSuccess ? SpinnerStatus.SUCCESS : SpinnerStatus.PROCESSING return ( <> - + {stepInfo.description} diff --git a/src/components/new-safe/create/styles.module.css b/src/components/new-safe/create/styles.module.css index 2cae562226..38feb0b251 100644 --- a/src/components/new-safe/create/styles.module.css +++ b/src/components/new-safe/create/styles.module.css @@ -3,7 +3,7 @@ padding: var(--space-4) var(--space-7); } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .row { padding: var(--space-2); } diff --git a/src/components/nfts/NftCollections/index.tsx b/src/components/nfts/NftCollections/index.tsx index 50118b301f..e9414f4854 100644 --- a/src/components/nfts/NftCollections/index.tsx +++ b/src/components/nfts/NftCollections/index.tsx @@ -1,9 +1,8 @@ -import { type SyntheticEvent, type ReactElement, useCallback, useEffect, useState } from 'react' +import { type SyntheticEvent, type ReactElement, useCallback, useEffect, useState, useContext } from 'react' import { type SafeCollectibleResponse } from '@safe-global/safe-gateway-typescript-sdk' import ErrorMessage from '@/components/tx/ErrorMessage' import PagePlaceholder from '@/components/common/PagePlaceholder' import NftIcon from '@/public/images/common/nft.svg' -import NftBatchModal from '@/components/tx/modals/NftBatchModal' import useCollectibles from '@/hooks/useCollectibles' import InfiniteScroll from '@/components/common/InfiniteScroll' import { NFT_EVENTS } from '@/services/analytics/events/nfts' @@ -11,6 +10,8 @@ import { trackEvent } from '@/services/analytics' import NftGrid from '../NftGrid' import NftSendForm from '../NftSendForm' import NftPreviewModal from '../NftPreviewModal' +import { TxModalContext } from '@/components/tx-flow' +import NftTransferFlow from '@/components/tx-flow/flows/NftTransfer' const NftCollections = (): ReactElement => { // Track the current NFT page url @@ -21,10 +22,10 @@ const NftCollections = (): ReactElement => { const [allNfts, setAllNfts] = useState([]) // Selected NFTs const [selectedNfts, setSelectedNfts] = useState([]) - // Modal open state - const [showSendModal, setShowSendModal] = useState(false) // Preview const [previewNft, setPreviewNft] = useState() + // Tx modal + const { setTxFlow } = useContext(TxModalContext) // On NFT preview click const onPreview = useCallback((token: SafeCollectibleResponse) => { @@ -38,13 +39,13 @@ const NftCollections = (): ReactElement => { if (selectedNfts.length) { // Show the NFT transfer modal - setShowSendModal(true) + setTxFlow() // Track how many NFTs are being sent trackEvent({ ...NFT_EVENTS.SEND, label: selectedNfts.length }) } }, - [selectedNfts.length], + [selectedNfts, setTxFlow], ) // Add new NFTs to the accumulated list @@ -84,19 +85,6 @@ const NftCollections = (): ReactElement => { )} - {/* Send NFT modal */} - {showSendModal && ( - setShowSendModal(false)} - initialData={[ - { - recipient: '', - tokens: selectedNfts, - }, - ]} - /> - )} - {/* NFT preview */} { setPreviewNft(undefined)} nft={previewNft} />} diff --git a/src/components/safe-apps/AppFrame/index.tsx b/src/components/safe-apps/AppFrame/index.tsx index 2852c3872a..a16eba2f65 100644 --- a/src/components/safe-apps/AppFrame/index.tsx +++ b/src/components/safe-apps/AppFrame/index.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useContext, useState } from 'react' import type { ReactElement } from 'react' import { useMemo } from 'react' import { useCallback, useEffect } from 'react' @@ -6,7 +6,14 @@ import { CircularProgress, Typography } from '@mui/material' import { useRouter } from 'next/router' import Head from 'next/head' import { getBalances, getTransactionDetails, getSafeMessage } from '@safe-global/safe-gateway-typescript-sdk' -import type { AddressBookItem, EIP712TypedData, RequestId, SafeSettings } from '@safe-global/safe-apps-sdk' +import type { + AddressBookItem, + BaseTransaction, + EIP712TypedData, + RequestId, + SafeSettings, + SendTransactionRequestParams, +} from '@safe-global/safe-apps-sdk' import { Methods } from '@safe-global/safe-apps-sdk' import { trackSafeAppOpenCount } from '@/services/safe-apps/track-app-usage-count' @@ -27,12 +34,7 @@ import useAnalyticsFromSafeApp from './useFromAppAnalytics' import useAppIsLoading from './useAppIsLoading' import useAppCommunicator, { CommunicatorMessages } from './useAppCommunicator' import { ThirdPartyCookiesWarning } from './ThirdPartyCookiesWarning' -import SafeAppsTxModal from '@/components/safe-apps/SafeAppsTxModal' -import useTxModal from '@/components/safe-apps/SafeAppsTxModal/useTxModal' -import SafeAppsSignMessageModal from '@/components/safe-apps/SafeAppsSignMessageModal' -import useSignMessageModal from '@/components/safe-apps/SignMessageModal/useSignMessageModal' import TransactionQueueBar, { TRANSACTION_BAR_HEIGHT } from './TransactionQueueBar' -import MsgModal from '@/components/safe-messages/MsgModal' import { safeMsgSubscribe, SafeMsgEvent } from '@/services/safe-messages/safeMsgEvents' import { useAppSelector } from '@/store' import { selectSafeMessages } from '@/store/safeMessagesSlice' @@ -46,6 +48,10 @@ import SafeAppIframe from './SafeAppIframe' import useGetSafeInfo from './useGetSafeInfo' import { hasFeature, FEATURES } from '@/utils/chains' import { selectTokenList, selectOnChainSigning, TOKEN_LISTS } from '@/store/settingsSlice' +import { TxModalContext } from '@/components/tx-flow' +import SafeAppsTxFlow from '@/components/tx-flow/flows/SafeAppsTx' +import SignMessageFlow from '@/components/tx-flow/flows/SignMessage' +import SignMessageOnChainFlow from '@/components/tx-flow/flows/SignMessageOnChain' const UNKNOWN_APP_NAME = 'Unknown Safe App' @@ -56,13 +62,12 @@ type AppFrameProps = { const AppFrame = ({ appUrl, allowedFeaturesList }: AppFrameProps): ReactElement => { const chainId = useChainId() - const [txModalState, openTxModal, closeTxModal] = useTxModal() // We use offChainSigning by default const [settings, setSettings] = useState({ offChainSigning: true, }) + const [currentRequestId, setCurrentRequestId] = useState() const safeMessages = useAppSelector(selectSafeMessages) - const [signMessageModalState, openSignMessageModal, closeSignMessageModal] = useSignMessageModal() const { safe, safeLoaded, safeAddress } = useSafeInfo() const tokenlist = useAppSelector(selectTokenList) const onChainSigning = useAppSelector(selectOnChainSigning) @@ -86,9 +91,31 @@ const AppFrame = ({ appUrl, allowedFeaturesList }: AppFrameProps): ReactElement const { getPermissions, hasPermission, permissionsRequest, setPermissionsRequest, confirmPermissionRequest } = useSafePermissions() const appName = useMemo(() => (remoteApp ? remoteApp.name : appUrl), [appUrl, remoteApp]) + const { setTxFlow } = useContext(TxModalContext) + + const onTxFlowClose = () => { + setCurrentRequestId((prevId) => { + if (prevId) { + communicator?.send(CommunicatorMessages.REJECT_TRANSACTION_MESSAGE, prevId, true) + trackSafeAppEvent(SAFE_APPS_EVENTS.PROPOSE_TRANSACTION_REJECTED, appName) + } + return undefined + }) + } const communicator = useAppCommunicator(iframeRef, remoteApp || safeAppFromManifest, chain, { - onConfirmTransactions: openTxModal, + onConfirmTransactions: (txs: BaseTransaction[], requestId: RequestId, params?: SendTransactionRequestParams) => { + const data = { + app: safeAppFromManifest, + appId: remoteApp ? String(remoteApp.id) : undefined, + requestId: requestId, + txs: txs, + params: params, + } + + setCurrentRequestId(requestId) + setTxFlow(, onTxFlowClose) + }, onSignMessage: ( message: string | EIP712TypedData, requestId: string, @@ -97,7 +124,33 @@ const AppFrame = ({ appUrl, allowedFeaturesList }: AppFrameProps): ReactElement ) => { const isOffChainSigningSupported = isOffchainEIP1271Supported(safe, chain, sdkVersion) const signOffChain = isOffChainSigningSupported && !onChainSigning - openSignMessageModal(message, requestId, method, signOffChain && !!settings.offChainSigning) + + setCurrentRequestId(requestId) + + if (signOffChain) { + setTxFlow( + , + onTxFlowClose, + ) + } else { + setTxFlow( + , + ) + } }, onGetPermissions: getPermissions, onSetPermissions: setPermissionsRequest, @@ -161,6 +214,20 @@ const AppFrame = ({ appUrl, allowedFeaturesList }: AppFrameProps): ReactElement }, }) + const onAcceptPermissionRequest = (_origin: string, requestId: RequestId) => { + const permissions = confirmPermissionRequest(PermissionStatus.GRANTED) + communicator?.send(permissions, requestId as string) + } + + const onRejectPermissionRequest = (requestId?: RequestId) => { + if (requestId) { + confirmPermissionRequest(PermissionStatus.DENIED) + communicator?.send('Permissions were rejected', requestId as string, true) + } else { + setPermissionsRequest(undefined) + } + } + useEffect(() => { if (!remoteApp) return @@ -190,55 +257,26 @@ const AppFrame = ({ appUrl, allowedFeaturesList }: AppFrameProps): ReactElement useEffect(() => { const unsubscribe = txSubscribe(TxEvent.SAFE_APPS_REQUEST, async ({ safeAppRequestId, safeTxHash }) => { - const currentSafeAppRequestId = signMessageModalState.requestId || txModalState.requestId - - if (currentSafeAppRequestId === safeAppRequestId) { + if (safeAppRequestId && currentRequestId === safeAppRequestId) { trackSafeAppEvent(SAFE_APPS_EVENTS.PROPOSE_TRANSACTION, appName) - communicator?.send({ safeTxHash }, safeAppRequestId) - - txModalState.isOpen ? closeTxModal() : closeSignMessageModal() + setTxFlow(undefined) } }) return unsubscribe - }, [appName, chainId, closeSignMessageModal, closeTxModal, communicator, signMessageModalState, txModalState]) + }, [appName, chainId, communicator, currentRequestId, setTxFlow]) useEffect(() => { const unsubscribe = safeMsgSubscribe(SafeMsgEvent.SIGNATURE_PREPARED, ({ messageHash, requestId, signature }) => { - if (signMessageModalState.requestId === requestId) { + if (requestId && currentRequestId === requestId) { communicator?.send({ messageHash, signature }, requestId) + setTxFlow(undefined) } }) return unsubscribe - }, [communicator, signMessageModalState.requestId]) - - const onSafeAppsModalClose = () => { - if (txModalState.isOpen) { - communicator?.send(CommunicatorMessages.REJECT_TRANSACTION_MESSAGE, txModalState.requestId, true) - closeTxModal() - } else { - communicator?.send(CommunicatorMessages.REJECT_TRANSACTION_MESSAGE, signMessageModalState.requestId, true) - closeSignMessageModal() - } - - trackSafeAppEvent(SAFE_APPS_EVENTS.PROPOSE_TRANSACTION_REJECTED, appName) - } - - const onAcceptPermissionRequest = (origin: string, requestId: RequestId) => { - const permissions = confirmPermissionRequest(PermissionStatus.GRANTED) - communicator?.send(permissions, requestId as string) - } - - const onRejectPermissionRequest = (requestId?: RequestId) => { - if (requestId) { - confirmPermissionRequest(PermissionStatus.DENIED) - communicator?.send('Permissions were rejected', requestId as string, true) - } else { - setPermissionsRequest(undefined) - } - } + }, [communicator, currentRequestId, setTxFlow]) if (!safeLoaded) { return
@@ -288,46 +326,6 @@ const AppFrame = ({ appUrl, allowedFeaturesList }: AppFrameProps): ReactElement transactions={transactions} /> - {txModalState.isOpen && ( - - )} - - {signMessageModalState.isOpen && - (signMessageModalState.isOffChain ? ( - - ) : ( - - ))} - {permissionsRequest && ( { document.body.removeChild(iframeRef.current as Node) } } catch (error) { - logError(Errors._905, (error as Error).message) + logError(Errors._905, error) } }, []) diff --git a/src/components/safe-apps/SafeAppLandingPage/index.tsx b/src/components/safe-apps/SafeAppLandingPage/index.tsx index 2f14c7c250..7567e014d1 100644 --- a/src/components/safe-apps/SafeAppLandingPage/index.tsx +++ b/src/components/safe-apps/SafeAppLandingPage/index.tsx @@ -42,7 +42,7 @@ const SafeAppLanding = ({ appUrl, chain }: Props) => { trackEvent(OVERVIEW_EVENTS.OPEN_ONBOARD) - onboard.connectWallet().catch((e) => logError(Errors._302, (e as Error).message)) + onboard.connectWallet().catch((e) => logError(Errors._302, e)) } const handleDemoClick = () => { diff --git a/src/components/safe-apps/SafeAppsHeader/styles.module.css b/src/components/safe-apps/SafeAppsHeader/styles.module.css index 95f75eb8c3..b400ca92f4 100644 --- a/src/components/safe-apps/SafeAppsHeader/styles.module.css +++ b/src/components/safe-apps/SafeAppsHeader/styles.module.css @@ -27,7 +27,7 @@ border-bottom: 1px solid var(--color-border-light); } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .tabs { padding: 0 24px; } diff --git a/src/components/safe-apps/SafeAppsModalLabel/index.tsx b/src/components/safe-apps/SafeAppsModalLabel/index.tsx deleted file mode 100644 index 6fbb02027c..0000000000 --- a/src/components/safe-apps/SafeAppsModalLabel/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' -import { Typography, Box } from '@mui/material' -import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard' - -const APP_LOGO_FALLBACK_IMAGE = '/images/apps/apps-icon.svg' - -const SafeAppsModalLabel = ({ app }: { app?: SafeAppData }) => { - if (!app) { - return Safe Apps Transaction - } - - return ( - - - - - {app.name} - - ) -} - -export default SafeAppsModalLabel diff --git a/src/components/safe-apps/SafeAppsSignMessageModal/SafeAppsSignMessageModal.test.tsx b/src/components/safe-apps/SafeAppsSignMessageModal/SafeAppsSignMessageModal.test.tsx deleted file mode 100644 index b6ab2d5633..0000000000 --- a/src/components/safe-apps/SafeAppsSignMessageModal/SafeAppsSignMessageModal.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Methods } from '@safe-global/safe-apps-sdk' -import * as web3 from '../../../hooks/wallets/web3' -import { Web3Provider } from '@ethersproject/providers' -import { render, screen } from '@/tests/test-utils' -import SafeAppsSignMessageModal from './' -import { SafeAppAccessPolicyTypes } from '@safe-global/safe-gateway-typescript-sdk' - -describe('SafeAppsSignMessageModal', () => { - test('can handle messages with EIP712Domain type in the JSON-RPC payload', () => { - jest.spyOn(web3, '_getWeb3').mockImplementation(() => new Web3Provider(jest.fn())) - - render( - {}} - initialData={[ - { - app: { - id: 73, - url: 'https://app.com', - name: 'App', - iconUrl: 'https://app.com/icon.png', - description: 'App description', - chainIds: ['1'], - tags: [], - features: [], - socialProfiles: [], - developerWebsite: '', - accessControl: { - type: SafeAppAccessPolicyTypes.NoRestrictions, - }, - }, - appId: 73, - requestId: '73', - message: { - types: { - Vote: [ - { - name: 'from', - type: 'address', - }, - { - name: 'space', - type: 'string', - }, - { - name: 'timestamp', - type: 'uint64', - }, - { - name: 'proposal', - type: 'bytes32', - }, - { - name: 'choice', - type: 'uint32', - }, - ], - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - ], - }, - domain: { - name: 'snapshot', - version: '0.1.4', - }, - message: { - from: '0x292bacf82268e143f5195af6928693699e31f911', - space: 'fabien.eth', - timestamp: '1663592967', - proposal: '0xbe992f0a433d2dbe2e0cee579e5e1bdb625cdcb3a14357ea990c6cdc3e129991', - choice: '1', - }, - }, - method: Methods.signTypedMessage, - }, - ]} - />, - ) - - expect(screen.getByText('Interact with SignMessageLib')).toBeInTheDocument() - }) -}) diff --git a/src/components/safe-apps/SafeAppsSignMessageModal/index.tsx b/src/components/safe-apps/SafeAppsSignMessageModal/index.tsx deleted file mode 100644 index c443b5d472..0000000000 --- a/src/components/safe-apps/SafeAppsSignMessageModal/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' -import type { EIP712TypedData, Methods, RequestId } from '@safe-global/safe-apps-sdk' -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import type { TxModalProps } from '@/components/tx/TxModal' -import TxModal from '@/components/tx/TxModal' -import SafeAppsModalLabel from '@/components/safe-apps/SafeAppsModalLabel' -import ReviewSafeAppsSignMessage from './ReviewSafeAppsSignMessage' - -export type SafeAppsSignMessageParams = { - appId?: number - app?: SafeAppData - requestId: RequestId - message: string | EIP712TypedData - method: Methods.signMessage | Methods.signTypedMessage -} - -const SafeAppsSignSteps: TxStepperProps['steps'] = [ - { - label: (data) => { - const { app } = data as SafeAppsSignMessageParams - - return - }, - render: (data) => { - return - }, - }, -] - -const SafeAppsSignMessageModal = ( - props: Omit & { initialData: [SafeAppsSignMessageParams] }, -) => { - return -} - -export default SafeAppsSignMessageModal diff --git a/src/components/safe-apps/SafeAppsTxModal/InvalidTransaction.tsx b/src/components/safe-apps/SafeAppsTxModal/InvalidTransaction.tsx deleted file mode 100644 index 650fe1edaa..0000000000 --- a/src/components/safe-apps/SafeAppsTxModal/InvalidTransaction.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { ReactElement } from 'react' -import { Box, SvgIcon, Typography } from '@mui/material' -import ErrorIcon from '@/public/images/notifications/error.svg' - -const InvalidTransaction = (): ReactElement => { - return ( - - - - Transaction error - -
- - This Safe App initiated a transaction which cannot be processed. Please get in touch with the developer of this - Safe App for more information. - -
- ) -} - -export default InvalidTransaction diff --git a/src/components/safe-apps/SafeAppsTxModal/ReviewSafeAppsTx.tsx b/src/components/safe-apps/SafeAppsTxModal/ReviewSafeAppsTx.tsx deleted file mode 100644 index 8e0cc66409..0000000000 --- a/src/components/safe-apps/SafeAppsTxModal/ReviewSafeAppsTx.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useMemo, useState } from 'react' -import type { ReactElement } from 'react' -import { ErrorBoundary } from '@sentry/react' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import SendFromBlock from '@/components/tx/SendFromBlock' -import SendToBlock from '@/components/tx/SendToBlock' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import useAsync from '@/hooks/useAsync' -import { useCurrentChain } from '@/hooks/useChains' -import { getInteractionTitle } from '../utils' -import type { SafeAppsTxParams } from '.' -import { trackSafeAppTxCount } from '@/services/safe-apps/track-app-usage-count' -import { getTxOrigin } from '@/utils/transactions' -import { ApprovalEditor } from '../../tx/ApprovalEditor' -import { createMultiSendCallOnlyTx, createTx, dispatchSafeAppsTx } from '@/services/tx/tx-sender' -import useOnboard from '@/hooks/wallets/useOnboard' -import useSafeInfo from '@/hooks/useSafeInfo' -import { Box, Typography } from '@mui/material' -import { generateDataRowValue } from '@/components/transactions/TxDetails/Summary/TxDataRow' -import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab' - -type ReviewSafeAppsTxProps = { - safeAppsTx: SafeAppsTxParams -} - -const ReviewSafeAppsTx = ({ - safeAppsTx: { txs, requestId, params, appId, app }, -}: ReviewSafeAppsTxProps): ReactElement => { - const { safe } = useSafeInfo() - const onboard = useOnboard() - const chain = useCurrentChain() - const [txList, setTxList] = useState(txs) - const [submitError, setSubmitError] = useState() - const isMultiSend = txList.length > 1 - - useHighlightHiddenTab() - - const [safeTx, safeTxError] = useAsync(async () => { - const tx = isMultiSend ? await createMultiSendCallOnlyTx(txList) : await createTx(txList[0]) - - if (params?.safeTxGas) { - // FIXME: do it properly via the Core SDK - // @ts-expect-error safeTxGas readonly - tx.data.safeTxGas = params.safeTxGas - } - - return tx - }, [txList]) - - const handleSubmit = async () => { - setSubmitError(undefined) - if (!safeTx || !onboard) return - trackSafeAppTxCount(Number(appId)) - - try { - await dispatchSafeAppsTx(safeTx, requestId, onboard, safe.chainId) - } catch (error) { - setSubmitError(error as Error) - } - } - - const origin = useMemo(() => getTxOrigin(app), [app]) - - return ( - - <> - Error parsing data
}> - - - - - - {safeTx && ( - <> - - - - - Data (hex encoded) - - {generateDataRowValue(safeTx.data.data, 'rawData')} - - - )} - - - ) -} - -export default ReviewSafeAppsTx diff --git a/src/components/safe-apps/SafeAppsTxModal/index.tsx b/src/components/safe-apps/SafeAppsTxModal/index.tsx deleted file mode 100644 index fa1f0207a3..0000000000 --- a/src/components/safe-apps/SafeAppsTxModal/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' -import type { BaseTransaction, RequestId, SendTransactionRequestParams } from '@safe-global/safe-apps-sdk' -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import type { TxModalProps } from '@/components/tx/TxModal' -import TxModal from '@/components/tx/TxModal' -import ReviewSafeAppsTx from './ReviewSafeAppsTx' -import InvalidTransaction from './InvalidTransaction' -import SafeAppsTxModalLabel from '@/components/safe-apps/SafeAppsModalLabel' -import { isTxValid } from '@/components/safe-apps/utils' - -export type SafeAppsTxParams = { - appId?: string - app?: SafeAppData - requestId: RequestId - txs: BaseTransaction[] - params?: SendTransactionRequestParams -} - -const SafeAppsTxSteps: TxStepperProps['steps'] = [ - { - label: (data) => { - const { app } = data as SafeAppsTxParams - - return - }, - render: (data) => { - if (!isTxValid((data as SafeAppsTxParams).txs)) { - return - } - - return - }, - }, -] - -const SafeAppsTxModal = (props: Omit) => { - return -} - -export default SafeAppsTxModal diff --git a/src/components/safe-apps/SafeAppsTxModal/useTxModal.ts b/src/components/safe-apps/SafeAppsTxModal/useTxModal.ts deleted file mode 100644 index dd847cf037..0000000000 --- a/src/components/safe-apps/SafeAppsTxModal/useTxModal.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useCallback, useState } from 'react' -import type { BaseTransaction, RequestId, SendTransactionRequestParams } from '@safe-global/safe-apps-sdk' - -type TxModalState = { - isOpen: boolean - txs: BaseTransaction[] - requestId: RequestId - params?: SendTransactionRequestParams -} - -const INITIAL_CONFIRM_TX_MODAL_STATE: TxModalState = { - isOpen: false, - txs: [], - requestId: '', - params: undefined, -} - -type ReturnType = [ - TxModalState, - (txs: BaseTransaction[], requestId: RequestId, params?: SendTransactionRequestParams) => void, - () => void, -] - -const useTxModal = (): ReturnType => { - const [txModalState, setTxModalState] = useState(INITIAL_CONFIRM_TX_MODAL_STATE) - - const openTxModal = useCallback( - (txs: BaseTransaction[], requestId: RequestId, params?: SendTransactionRequestParams) => - setTxModalState({ - isOpen: true, - txs, - requestId, - params, - }), - [], - ) - - const closeTxModal = useCallback(() => setTxModalState(INITIAL_CONFIRM_TX_MODAL_STATE), []) - - return [txModalState, openTxModal, closeTxModal] -} - -export default useTxModal diff --git a/src/components/safe-apps/SignMessageModal/useSignMessageModal.ts b/src/components/safe-apps/SignMessageModal/useSignMessageModal.ts deleted file mode 100644 index ca2ea268ca..0000000000 --- a/src/components/safe-apps/SignMessageModal/useSignMessageModal.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useState, useCallback } from 'react' -import type { EIP712TypedData } from '@safe-global/safe-apps-sdk' -import { Methods } from '@safe-global/safe-apps-sdk' - -type StateType = { - isOpen: boolean - message: string | EIP712TypedData - requestId: string - method: Methods - isOffChain: boolean -} - -const INITIAL_MODAL_STATE: StateType = { - isOpen: false, - message: '', - requestId: '', - method: Methods.signMessage, - isOffChain: false, -} - -type ReturnType = [ - StateType, - ( - message: string | EIP712TypedData, - requestId: string, - method: Methods.signMessage | Methods.signTypedMessage, - isOffChain: boolean, - ) => void, - () => void, -] - -const useSignMessageModal = (): ReturnType => { - const [signMessageModalState, setSignMessageModalState] = useState(INITIAL_MODAL_STATE) - - const openSignMessageModal = useCallback( - ( - message: string | EIP712TypedData, - requestId: string, - method: Methods, - isOffChain = INITIAL_MODAL_STATE.isOffChain, - ) => { - setSignMessageModalState({ - ...INITIAL_MODAL_STATE, - isOpen: true, - message, - requestId, - method, - isOffChain, - }) - }, - [], - ) - - const closeSignMessageModal = useCallback(() => { - setSignMessageModalState(INITIAL_MODAL_STATE) - }, []) - - return [signMessageModalState, openSignMessageModal, closeSignMessageModal] -} - -export default useSignMessageModal diff --git a/src/components/safe-messages/InfoBox/index.tsx b/src/components/safe-messages/InfoBox/index.tsx index b644c68d5a..21a22a5c6c 100644 --- a/src/components/safe-messages/InfoBox/index.tsx +++ b/src/components/safe-messages/InfoBox/index.tsx @@ -5,10 +5,12 @@ import InfoIcon from '@/public/images/notifications/info.svg' import css from './styles.module.css' const InfoBox = ({ + title, message, children, className, }: { + title: string message: string children: ReactNode className?: string @@ -16,14 +18,15 @@ const InfoBox = ({ return (
- +
- - {message} + + {title} + {message}
- +
{children}
) diff --git a/src/components/safe-messages/InfoBox/styles.module.css b/src/components/safe-messages/InfoBox/styles.module.css index 2de4715456..ae24d95400 100644 --- a/src/components/safe-messages/InfoBox/styles.module.css +++ b/src/components/safe-messages/InfoBox/styles.module.css @@ -1,7 +1,6 @@ .container { background-color: var(--color-info-background); padding: var(--space-2); - margin: var(--space-2) 0; border-radius: 4px; display: flex; flex-direction: column; @@ -19,12 +18,14 @@ text-decoration: underline; } -.message svg { - margin-top: 4px; -} - .details { margin-top: var(--space-1); color: var(--color-primary-light); word-break: break-word; } + +.divider { + margin-right: calc(-1 * var(--space-2)); + margin-left: calc(-1 * var(--space-2)); + border-color: var(--color-info-light); +} diff --git a/src/components/safe-messages/MsgModal/ConfirmationDialog.tsx b/src/components/safe-messages/MsgModal/ConfirmationDialog.tsx deleted file mode 100644 index 3c73abc02c..0000000000 --- a/src/components/safe-messages/MsgModal/ConfirmationDialog.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Dialog, DialogTitle, DialogContent, DialogContentText, Typography, DialogActions, Button } from '@mui/material' - -export const ConfirmationDialog = ({ - open, - onCancel, - onClose, -}: { - open: boolean - onCancel: () => void - onClose: () => void -}) => ( - - Cancel message signing request - - - If you close this modal, the signing request will be aborted. - - - - - - - -) diff --git a/src/components/safe-messages/MsgSigners/index.tsx b/src/components/safe-messages/MsgSigners/index.tsx index 045a1602bd..a0e0c59938 100644 --- a/src/components/safe-messages/MsgSigners/index.tsx +++ b/src/components/safe-messages/MsgSigners/index.tsx @@ -79,7 +79,7 @@ export const MsgSigners = ({ )} - + @@ -92,7 +92,7 @@ export const MsgSigners = ({ {!hideConfirmations && confirmations.map(({ owner }) => ( - + @@ -115,7 +115,7 @@ export const MsgSigners = ({ {showMissingSignatures && missingConfirmations.map((_, idx) => ( - + @@ -130,7 +130,7 @@ export const MsgSigners = ({ ))} {isConfirmed && ( - + Confirmed diff --git a/src/components/safe-messages/MsgSigners/styles.module.css b/src/components/safe-messages/MsgSigners/styles.module.css index 7a6581ed4a..7d4aa34ecf 100644 --- a/src/components/safe-messages/MsgSigners/styles.module.css +++ b/src/components/safe-messages/MsgSigners/styles.module.css @@ -30,6 +30,11 @@ padding-right: 0; } +.signers :global .MuiListItemText-root { + margin-top: var(--space-1); + margin-bottom: var(--space-1); +} + .signers :global .MuiListItemIcon-root { color: var(--color-primary-main); justify-content: center; diff --git a/src/components/safe-messages/SignMsgButton/index.tsx b/src/components/safe-messages/SignMsgButton/index.tsx index 29d46336f9..9f5339ef4a 100644 --- a/src/components/safe-messages/SignMsgButton/index.tsx +++ b/src/components/safe-messages/SignMsgButton/index.tsx @@ -1,5 +1,5 @@ import { Button, Tooltip, IconButton } from '@mui/material' -import { useState } from 'react' +import { useContext } from 'react' import CheckIcon from '@mui/icons-material/Check' import type { SyntheticEvent, ReactElement } from 'react' import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' @@ -9,41 +9,38 @@ import Track from '@/components/common/Track' import { MESSAGE_EVENTS } from '@/services/analytics/events/txList' import useIsSafeMessageSignableBy from '@/hooks/messages/useIsSafeMessageSignableBy' import useIsSafeMessagePending from '@/hooks/messages/useIsSafeMessagePending' -import MsgModal from '@/components/safe-messages/MsgModal' +import { TxModalContext } from '@/components/tx-flow' +import SignMessageFlow from '@/components/tx-flow/flows/SignMessage' const SignMsgButton = ({ msg, compact = false }: { msg: SafeMessage; compact?: boolean }): ReactElement => { - const [open, setOpen] = useState(false) const wallet = useWallet() const isSignable = useIsSafeMessageSignableBy(msg, wallet?.address || '') const isPending = useIsSafeMessagePending(msg.messageHash) + const { setTxFlow } = useContext(TxModalContext) const onClick = (e: SyntheticEvent) => { e.stopPropagation() - setOpen(true) + setTxFlow() } const isDisabled = !isSignable || isPending return ( - <> - - {compact ? ( - - - - - - - - ) : ( - - )} - - - {open && setOpen(false)} {...msg} />} - + + {compact ? ( + + + + + + + + ) : ( + + )} + ) } diff --git a/src/components/settings/ContractVersion/index.tsx b/src/components/settings/ContractVersion/index.tsx index 491d1814e7..a1a7feac40 100644 --- a/src/components/settings/ContractVersion/index.tsx +++ b/src/components/settings/ContractVersion/index.tsx @@ -1,5 +1,5 @@ -import { useMemo } from 'react' -import { Box, SvgIcon, Typography, Alert, AlertTitle, Skeleton } from '@mui/material' +import { useContext, useMemo } from 'react' +import { Box, SvgIcon, Typography, Alert, AlertTitle, Skeleton, Button } from '@mui/material' import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' import { LATEST_SAFE_VERSION } from '@/config/constants' import { sameAddress } from '@/utils/addresses' @@ -8,10 +8,13 @@ import { MasterCopyDeployer, useMasterCopies } from '@/hooks/useMasterCopies' import useSafeInfo from '@/hooks/useSafeInfo' import CheckCircleIcon from '@mui/icons-material/CheckCircle' import InfoIcon from '@/public/images/notifications/info.svg' - -import UpdateSafeDialog from './UpdateSafeDialog' +import { TxModalContext } from '@/components/tx-flow' +import UpdateSafeFlow from '@/components/tx-flow/flows/UpdateSafe' import ExternalLink from '@/components/common/ExternalLink' +import CheckWallet from '@/components/common/CheckWallet' + export const ContractVersion = () => { + const { setTxFlow } = useContext(TxModalContext) const [masterCopies] = useMasterCopies() const { safe, safeLoaded } = useSafeInfo() const masterCopyAddress = safe.implementation.value @@ -40,12 +43,20 @@ export const ContractVersion = () => { icon={} > New version is available: {LATEST_SAFE_VERSION} + Update now to take advantage of new features and the highest security standards available. You will need to confirm this update just like any other transaction.{' '} GitHub - + + + {(isOk) => ( + + )} + ) : ( diff --git a/src/components/settings/DataManagement/useGlobalImportFileParser.ts b/src/components/settings/DataManagement/useGlobalImportFileParser.ts index 767776d259..b7c535c1e6 100644 --- a/src/components/settings/DataManagement/useGlobalImportFileParser.ts +++ b/src/components/settings/DataManagement/useGlobalImportFileParser.ts @@ -92,7 +92,7 @@ export const useGlobalImportJsonParser = (jsonData: string | undefined): Data => try { parsedFile = JSON.parse(jsonData) } catch (err) { - logError(ErrorCodes._704, (err as Error).message) + logError(ErrorCodes._704, err) data.error = ImportErrors.INVALID_JSON_FORMAT return data diff --git a/src/components/settings/RequiredConfirmations/index.tsx b/src/components/settings/RequiredConfirmations/index.tsx index 74837884a2..5b05b4eb3b 100644 --- a/src/components/settings/RequiredConfirmations/index.tsx +++ b/src/components/settings/RequiredConfirmations/index.tsx @@ -1,7 +1,14 @@ -import { ChangeThresholdDialog } from '@/components/settings/owner/ChangeThresholdDialog' -import { Box, Grid, Typography } from '@mui/material' +import { Box, Button, Grid, Typography } from '@mui/material' +import Track from '@/components/common/Track' +import { SETTINGS_EVENTS } from '@/services/analytics' +import ChangeThresholdFlow from '@/components/tx-flow/flows/ChangeThreshold' +import CheckWallet from '@/components/common/CheckWallet' +import { useContext } from 'react' +import { TxModalContext } from '@/components/tx-flow' export const RequiredConfirmation = ({ threshold, owners }: { threshold: number; owners: number }) => { + const { setTxFlow } = useContext(TxModalContext) + return ( @@ -17,7 +24,19 @@ export const RequiredConfirmation = ({ threshold, owners }: { threshold: number; {threshold} out of {owners} owners. - {owners > 1 && } + {owners > 1 && ( + + + {(isOk) => ( + + + + )} + + + )}
diff --git a/src/components/settings/SafeModules/RemoveModule/index.tsx b/src/components/settings/SafeModules/RemoveModule/index.tsx deleted file mode 100644 index 6423c420e8..0000000000 --- a/src/components/settings/SafeModules/RemoveModule/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import { useState } from 'react' -import { IconButton, SvgIcon } from '@mui/material' -import TxModal from '@/components/tx/TxModal' -import DeleteIcon from '@/public/images/common/delete.svg' -import { ReviewRemoveModule } from '@/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule' -import CheckWallet from '@/components/common/CheckWallet' - -export type RemoveModuleData = { - address: string -} - -const RemoveModuleSteps: TxStepperProps['steps'] = [ - { - label: 'Remove module', - render: (data, onSubmit) => , - }, -] - -export const RemoveModule = ({ address }: { address: string }) => { - const [open, setOpen] = useState(false) - - const initialData = { - address, - } - - return ( - <> - - {(isOk) => ( - setOpen(true)} color="error" size="small" disabled={!isOk}> - - - )} - - - {open && setOpen(false)} steps={RemoveModuleSteps} initialData={[initialData]} />} - - ) -} diff --git a/src/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule.tsx b/src/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule.tsx deleted file mode 100644 index cef844ad24..0000000000 --- a/src/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import useAsync from '@/hooks/useAsync' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import { Typography } from '@mui/material' -import SendToBlock from '@/components/tx/SendToBlock' -import type { RemoveModuleData } from '@/components/settings/SafeModules/RemoveModule' -import { useEffect } from 'react' -import { Errors, logError } from '@/services/exceptions' -import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' -import { createRemoveModuleTx } from '@/services/tx/tx-sender' - -export const ReviewRemoveModule = ({ data, onSubmit }: { data: RemoveModuleData; onSubmit: () => void }) => { - const [safeTx, safeTxError] = useAsync(() => { - return createRemoveModuleTx(data.address) - }, [data.address]) - - useEffect(() => { - if (safeTxError) { - logError(Errors._806, safeTxError.message) - } - }, [safeTxError]) - - const onFormSubmit = () => { - trackEvent(SETTINGS_EVENTS.MODULES.REMOVE_MODULE) - - onSubmit() - } - - return ( - - - - After removing this module, any feature or app that uses this module might no longer work. If this Safe Account - requires more then one signature, the module removal will have to be confirmed by other owners as well. - - - ) -} diff --git a/src/components/settings/SafeModules/index.tsx b/src/components/settings/SafeModules/index.tsx index 26695c9ec1..9c1dca7c56 100644 --- a/src/components/settings/SafeModules/index.tsx +++ b/src/components/settings/SafeModules/index.tsx @@ -1,10 +1,14 @@ import EthHashInfo from '@/components/common/EthHashInfo' import useSafeInfo from '@/hooks/useSafeInfo' -import { Paper, Grid, Typography, Box } from '@mui/material' +import { Paper, Grid, Typography, Box, IconButton, SvgIcon } from '@mui/material' import css from './styles.module.css' -import { RemoveModule } from '@/components/settings/SafeModules/RemoveModule' import ExternalLink from '@/components/common/ExternalLink' +import RemoveModuleFlow from '@/components/tx-flow/flows/RemoveModule' +import DeleteIcon from '@/public/images/common/delete.svg' +import CheckWallet from '@/components/common/CheckWallet' +import { useContext } from 'react' +import { TxModalContext } from '@/components/tx-flow' const NoModules = () => { return ( @@ -15,6 +19,8 @@ const NoModules = () => { } const ModuleDisplay = ({ moduleAddress, chainId, name }: { moduleAddress: string; chainId: string; name?: string }) => { + const { setTxFlow } = useContext(TxModalContext) + return ( - + + {(isOk) => ( + setTxFlow()} + color="error" + size="small" + disabled={!isOk} + > + + + )} + ) } diff --git a/src/components/settings/SpendingLimits/NewSpendingLimit/index.tsx b/src/components/settings/SpendingLimits/NewSpendingLimit/index.tsx deleted file mode 100644 index fbaf98d1b4..0000000000 --- a/src/components/settings/SpendingLimits/NewSpendingLimit/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Button } from '@mui/material' -import { useState } from 'react' -import TxModal from '@/components/tx/TxModal' -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import { SpendingLimitForm } from '@/components/settings/SpendingLimits/NewSpendingLimit/steps/SpendingLimitForm' -import { ReviewSpendingLimit } from '@/components/settings/SpendingLimits/NewSpendingLimit/steps/ReviewSpendingLimit' -import Track from '@/components/common/Track' -import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' -import CheckWallet from '@/components/common/CheckWallet' - -const NewSpendingLimitSteps: TxStepperProps['steps'] = [ - { - label: 'New spending limit', - render: (data, onSubmit) => , - }, - { - label: 'Review spending limit', - render: (data, onSubmit) => , - }, -] - -export type NewSpendingLimitData = { - beneficiary: string - tokenAddress: string - amount: string - resetTime: string -} - -export const NewSpendingLimit = () => { - const [open, setOpen] = useState(false) - - return ( - <> - - {(isOk) => ( - - - - )} - - - {open && setOpen(false)} steps={NewSpendingLimitSteps} />} - - ) -} diff --git a/src/components/settings/SpendingLimits/NewSpendingLimit/steps/ReviewSpendingLimit.tsx b/src/components/settings/SpendingLimits/NewSpendingLimit/steps/ReviewSpendingLimit.tsx deleted file mode 100644 index 61f8be0757..0000000000 --- a/src/components/settings/SpendingLimits/NewSpendingLimit/steps/ReviewSpendingLimit.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { Typography, Box } from '@mui/material' -import useBalances from '@/hooks/useBalances' -import { useEffect, useMemo, useState } from 'react' -import useAsync from '@/hooks/useAsync' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import EthHashInfo from '@/components/common/EthHashInfo' -import useChainId from '@/hooks/useChainId' -import { useSelector } from 'react-redux' -import type { SpendingLimitState } from '@/store/spendingLimitsSlice' -import { selectSpendingLimits } from '@/store/spendingLimitsSlice' -import { getResetTimeOptions } from '@/components/transactions/TxDetails/TxData/SpendingLimits' -import { BigNumber } from '@ethersproject/bignumber' -import { formatVisualAmount } from '@/utils/formatters' -import { relativeTime } from '@/utils/date' -import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' -import { TokenTransferReview } from '@/components/tx/modals/TokenTransferModal/ReviewTokenTx' -import SpendingLimitLabel from '@/components/common/SpendingLimitLabel' -import type { NewSpendingLimitData } from '@/services/tx/tx-sender' -import { createNewSpendingLimitTx } from '@/services/tx/tx-sender' - -type Props = { - data: NewSpendingLimitData - onSubmit: () => void -} - -export const ReviewSpendingLimit = ({ data, onSubmit }: Props) => { - const [existingSpendingLimit, setExistingSpendingLimit] = useState() - const spendingLimits = useSelector(selectSpendingLimits) - const chainId = useChainId() - const { balances } = useBalances() - - useEffect(() => { - const existingSpendingLimit = spendingLimits.find( - (spendingLimit) => - spendingLimit.beneficiary === data.beneficiary && spendingLimit.token.address === data.tokenAddress, - ) - setExistingSpendingLimit(existingSpendingLimit) - }, [spendingLimits, data]) - - const token = balances.items.find((item) => item.tokenInfo.address === data.tokenAddress) - const { decimals, symbol } = token?.tokenInfo || {} - - const isOneTime = data.resetTime === '0' - const resetTime = useMemo(() => { - return isOneTime - ? 'One-time spending limit' - : getResetTimeOptions(chainId).find((time) => time.value === data.resetTime)?.label - }, [isOneTime, data.resetTime, chainId]) - - const [safeTx, safeTxError] = useAsync(() => { - return createNewSpendingLimitTx(data, spendingLimits, chainId, decimals, existingSpendingLimit) - }, [data, spendingLimits, chainId, decimals, existingSpendingLimit]) - - const onFormSubmit = () => { - trackEvent({ - ...SETTINGS_EVENTS.SPENDING_LIMIT.RESET_PERIOD, - label: resetTime, - }) - - onSubmit() - } - - return ( - - {token && ( - - {!!existingSpendingLimit && ( - <> - - {formatVisualAmount(BigNumber.from(existingSpendingLimit.amount), decimals)} {symbol} - - {' → '} - - )} - - )} - palette.text.secondary} pb={1}> - Beneficiary - - - - - - - palette.text.secondary}>Reset time - {existingSpendingLimit ? ( - <> - - {existingSpendingLimit.resetTimeMin !== data.resetTime && ( - <> - - {relativeTime(existingSpendingLimit.lastResetMin, existingSpendingLimit.resetTimeMin)} - - {' → '} - - )} - - {resetTime} - - - } - isOneTime={existingSpendingLimit.resetTimeMin === '0'} - mb={2} - /> - - You are about to replace an existent spending limit - - - ) : ( - - )} - - ) -} diff --git a/src/components/settings/SpendingLimits/NewSpendingLimit/steps/SpendingLimitForm.tsx b/src/components/settings/SpendingLimits/NewSpendingLimit/steps/SpendingLimitForm.tsx deleted file mode 100644 index b5b78c1cad..0000000000 --- a/src/components/settings/SpendingLimits/NewSpendingLimit/steps/SpendingLimitForm.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { useMemo, useState } from 'react' -import { FormProvider, useForm, Controller } from 'react-hook-form' -import { - Button, - DialogContent, - FormControl, - InputLabel, - MenuItem, - Select, - Switch, - Typography, - RadioGroup, - FormControlLabel, - Radio, - FormGroup, -} from '@mui/material' -import AddressBookInput from '@/components/common/AddressBookInput' -import { validateAmount, validateDecimalLength } from '@/utils/validation' -import { AutocompleteItem } from '@/components/tx/modals/TokenTransferModal/SendAssetsForm' -import useChainId from '@/hooks/useChainId' -import { getResetTimeOptions } from '@/components/transactions/TxDetails/TxData/SpendingLimits' -import { defaultAbiCoder } from '@ethersproject/abi' -import { parseUnits } from 'ethers/lib/utils' -import NumberField from '@/components/common/NumberField' -import { useVisibleBalances } from '@/hooks/useVisibleBalances' - -export type NewSpendingLimitData = { - beneficiary: string - tokenAddress: string - amount: string - resetTime: string -} - -type Props = { - data?: NewSpendingLimitData - onSubmit: (data: NewSpendingLimitData) => void -} - -export const _validateSpendingLimit = (val: string, decimals?: number) => { - // Allowance amount is uint96 https://github.com/safe-global/safe-modules/blob/master/allowances/contracts/AlowanceModule.sol#L52 - try { - const amount = parseUnits(val, decimals) - defaultAbiCoder.encode(['int96'], [amount]) - } catch (e) { - return Number(val) > 1 ? 'Amount is too big' : 'Amount is too small' - } -} - -export const SpendingLimitForm = ({ data, onSubmit }: Props) => { - const chainId = useChainId() - const [showResetTime, setShowResetTime] = useState(false) - const { balances } = useVisibleBalances() - - const resetTimeOptions = useMemo(() => getResetTimeOptions(chainId), [chainId]) - - const formMethods = useForm({ - defaultValues: { ...data, resetTime: '0' }, - mode: 'onChange', - }) - const { - register, - handleSubmit, - setValue, - watch, - control, - formState: { errors }, - } = formMethods - - const tokenAddress = watch('tokenAddress') - const selectedToken = tokenAddress - ? balances.items.find((item) => item.tokenInfo.address === tokenAddress) - : undefined - - const toggleResetTime = () => { - setValue('resetTime', showResetTime ? '0' : resetTimeOptions[0].value) - setShowResetTime((prev) => !prev) - } - - return ( - -
- - - - - - - - Select an asset - - - - - - { - const decimals = selectedToken?.tokenInfo.decimals - return ( - validateAmount(val) || - validateDecimalLength(val, decimals) || - _validateSpendingLimit(val, selectedToken?.tokenInfo.decimals) - ) - }, - })} - /> - - - Set a reset time so the allowance automatically refills after the defined time period. - - - - } - label={`Reset time (${showResetTime ? 'choose reset time period' : 'one time'})`} - /> - - - {showResetTime && ( - - ( - - {resetTimeOptions.map((resetTime) => ( - } - /> - ))} - - )} - /> - - )} - - - -
-
- ) -} diff --git a/src/components/settings/SpendingLimits/RemoveSpendingLimit/index.tsx b/src/components/settings/SpendingLimits/RemoveSpendingLimit/index.tsx deleted file mode 100644 index f65ef2fdda..0000000000 --- a/src/components/settings/SpendingLimits/RemoveSpendingLimit/index.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import { getSpendingLimitInterface, getSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts' -import useChainId from '@/hooks/useChainId' -import useAsync from '@/hooks/useAsync' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import EthHashInfo from '@/components/common/EthHashInfo' -import { Typography } from '@mui/material' -import type { SpendingLimitState } from '@/store/spendingLimitsSlice' -import { relativeTime } from '@/utils/date' -import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' -import useBalances from '@/hooks/useBalances' -import { TokenTransferReview } from '@/components/tx/modals/TokenTransferModal/ReviewTokenTx' -import { safeFormatUnits } from '@/utils/formatters' -import SpendingLimitLabel from '@/components/common/SpendingLimitLabel' -import { createTx } from '@/services/tx/tx-sender' - -export const RemoveSpendingLimit = ({ data, onSubmit }: { data: SpendingLimitState; onSubmit: () => void }) => { - const chainId = useChainId() - const { balances } = useBalances() - const token = balances.items.find((item) => item.tokenInfo.address === data.token.address) - - const [safeTx, safeTxError] = useAsync(() => { - const spendingLimitAddress = getSpendingLimitModuleAddress(chainId) - if (!spendingLimitAddress) return - - const spendingLimitInterface = getSpendingLimitInterface() - const txData = spendingLimitInterface.encodeFunctionData('deleteAllowance', [data.beneficiary, data.token.address]) - - const txParams = { - to: spendingLimitAddress, - value: '0', - data: txData, - } - - return createTx(txParams) - }, [chainId, data.beneficiary, data.token]) - - const onFormSubmit = () => { - trackEvent(SETTINGS_EVENTS.SPENDING_LIMIT.LIMIT_REMOVED) - - onSubmit() - } - - return ( - - {token && ( - - )} - ({ color: palette.primary.light })}>Beneficiary - - ({ color: palette.primary.light })}> - Reset time - - - - ) -} diff --git a/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx b/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx index 854c6d7549..fff812535f 100644 --- a/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx +++ b/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx @@ -4,25 +4,17 @@ import { safeFormatUnits } from '@/utils/formatters' import { Box, IconButton, Skeleton, SvgIcon, Typography } from '@mui/material' import { relativeTime } from '@/utils/date' import EthHashInfo from '@/components/common/EthHashInfo' -import { useMemo, useState } from 'react' +import { useContext, useMemo } from 'react' import type { SpendingLimitState } from '@/store/spendingLimitsSlice' import { BigNumber } from '@ethersproject/bignumber' -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import TxModal from '@/components/tx/TxModal' -import { RemoveSpendingLimit } from '@/components/settings/SpendingLimits/RemoveSpendingLimit' +import RemoveSpendingLimitFlow from '@/components/tx-flow/flows/RemoveSpendingLimit' +import { TxModalContext } from '@/components/tx-flow' import Track from '@/components/common/Track' import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' import TokenIcon from '@/components/common/TokenIcon' import SpendingLimitLabel from '@/components/common/SpendingLimitLabel' import CheckWallet from '@/components/common/CheckWallet' -const RemoveSpendingLimitSteps: TxStepperProps['steps'] = [ - { - label: 'Remove spending limit', - render: (data, onSubmit) => , - }, -] - const SKELETON_ROWS = new Array(3).fill('').map(() => { return { cells: { @@ -72,13 +64,7 @@ export const SpendingLimitsTable = ({ spendingLimits: SpendingLimitState[] isLoading: boolean }) => { - const [open, setOpen] = useState(false) - const [initialData, setInitialData] = useState() - - const onRemove = (spendingLimit: SpendingLimitState) => { - setOpen(true) - setInitialData(spendingLimit) - } + const { setTxFlow } = useContext(TxModalContext) const headCells = useMemo( () => [ @@ -135,7 +121,7 @@ export const SpendingLimitsTable = ({ {(isOk) => ( onRemove(spendingLimit)} + onClick={() => setTxFlow()} color="error" size="small" disabled={!isOk} @@ -150,12 +136,7 @@ export const SpendingLimitsTable = ({ }, } }), - [isLoading, spendingLimits], + [isLoading, setTxFlow, spendingLimits], ) - return spendingLimits.length > 0 ? ( - <> - - {open && setOpen(false)} steps={RemoveSpendingLimitSteps} initialData={[initialData]} />} - - ) : null + return spendingLimits.length > 0 ? : null } diff --git a/src/components/settings/SpendingLimits/index.tsx b/src/components/settings/SpendingLimits/index.tsx index d79d0fa919..148313cdef 100644 --- a/src/components/settings/SpendingLimits/index.tsx +++ b/src/components/settings/SpendingLimits/index.tsx @@ -1,13 +1,19 @@ -import { Paper, Grid, Typography, Box } from '@mui/material' +import { useContext } from 'react' +import { Paper, Grid, Typography, Box, Button } from '@mui/material' import { NoSpendingLimits } from '@/components/settings/SpendingLimits/NoSpendingLimits' import { SpendingLimitsTable } from '@/components/settings/SpendingLimits/SpendingLimitsTable' import { useSelector } from 'react-redux' import { selectSpendingLimits, selectSpendingLimitsLoading } from '@/store/spendingLimitsSlice' -import { NewSpendingLimit } from '@/components/settings/SpendingLimits/NewSpendingLimit' import { FEATURES } from '@/utils/chains' import { useHasFeature } from '@/hooks/useChains' +import NewSpendingLimitFlow from '@/components/tx-flow/flows/NewSpendingLimit' +import { SETTINGS_EVENTS } from '@/services/analytics' +import CheckWallet from '@/components/common/CheckWallet' +import Track from '@/components/common/Track' +import { TxModalContext } from '@/components/tx-flow' const SpendingLimits = () => { + const { setTxFlow } = useContext(TxModalContext) const spendingLimits = useSelector(selectSpendingLimits) const spendingLimitsLoading = useSelector(selectSpendingLimitsLoading) const isEnabled = useHasFeature(FEATURES.SPENDING_LIMIT) @@ -29,7 +35,20 @@ const SpendingLimits = () => { collect all signatures. - + + {(isOk) => ( + + + + )} + {!spendingLimits.length && !spendingLimitsLoading && } diff --git a/src/components/settings/TransactionGuards/RemoveGuard/index.tsx b/src/components/settings/TransactionGuards/RemoveGuard/index.tsx deleted file mode 100644 index f54414cfb2..0000000000 --- a/src/components/settings/TransactionGuards/RemoveGuard/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useState } from 'react' -import { IconButton, SvgIcon } from '@mui/material' -import DeleteIcon from '@/public/images/common/delete.svg' - -import TxModal from '@/components/tx/TxModal' -import { ReviewRemoveGuard } from '@/components/settings/TransactionGuards/RemoveGuard/steps/ReviewRemoveGuard' -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import CheckWallet from '@/components/common/CheckWallet' - -export type RemoveGuardData = { - address: string -} - -const RemoveGuardSteps: TxStepperProps['steps'] = [ - { - label: 'Remove transaction guard', - render: (data, onSubmit) => , - }, -] - -export const RemoveGuard = ({ address }: { address: string }) => { - const [open, setOpen] = useState(false) - - const initialData: RemoveGuardData = { - address, - } - - return ( - <> - - {(isOk) => ( - setOpen(true)} color="error" size="small" disabled={!isOk}> - - - )} - - - {open && setOpen(false)} steps={RemoveGuardSteps} initialData={[initialData]} />} - - ) -} diff --git a/src/components/settings/TransactionGuards/index.tsx b/src/components/settings/TransactionGuards/index.tsx index 00e11b4f21..484fe8e46c 100644 --- a/src/components/settings/TransactionGuards/index.tsx +++ b/src/components/settings/TransactionGuards/index.tsx @@ -1,13 +1,17 @@ import EthHashInfo from '@/components/common/EthHashInfo' import useSafeInfo from '@/hooks/useSafeInfo' -import { Paper, Grid, Typography, Box } from '@mui/material' -import { RemoveGuard } from './RemoveGuard' +import { Paper, Grid, Typography, Box, IconButton, SvgIcon } from '@mui/material' import css from './styles.module.css' import ExternalLink from '@/components/common/ExternalLink' import { SAFE_FEATURES } from '@safe-global/safe-core-sdk-utils' import { hasSafeFeature } from '@/utils/safe-versions' import { HelpCenterArticle } from '@/config/constants' +import DeleteIcon from '@/public/images/common/delete.svg' +import CheckWallet from '@/components/common/CheckWallet' +import { useContext } from 'react' +import { TxModalContext } from '@/components/tx-flow' +import RemoveGuardFlow from '@/components/tx-flow/flows/RemoveGuard' const NoTransactionGuard = () => { return ( @@ -18,10 +22,23 @@ const NoTransactionGuard = () => { } const GuardDisplay = ({ guardAddress, chainId }: { guardAddress: string; chainId: string }) => { + const { setTxFlow } = useContext(TxModalContext) + return ( - + + {(isOk) => ( + setTxFlow()} + color="error" + size="small" + disabled={!isOk} + > + + + )} + ) } diff --git a/src/components/settings/owner/AddOwnerDialog/DialogSteps/ChooseOwnerStep.tsx b/src/components/settings/owner/AddOwnerDialog/DialogSteps/ChooseOwnerStep.tsx deleted file mode 100644 index a89bb63a91..0000000000 --- a/src/components/settings/owner/AddOwnerDialog/DialogSteps/ChooseOwnerStep.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import AddressBookInput from '@/components/common/AddressBookInput' -import EthHashInfo from '@/components/common/EthHashInfo' -import NameInput from '@/components/common/NameInput' -import type { ChangeOwnerData, OwnerData } from '@/components/settings/owner/AddOwnerDialog/DialogSteps/types' -import useSafeInfo from '@/hooks/useSafeInfo' -import { addressIsNotCurrentSafe, uniqueAddress } from '@/utils/validation' -import { Box, Button, CircularProgress, DialogContent, FormControl, InputAdornment, Typography } from '@mui/material' -import { FormProvider, useForm } from 'react-hook-form' -import { useAddressResolver } from '@/hooks/useAddressResolver' - -export const ChooseOwnerStep = ({ - data, - onSubmit, -}: { - data: ChangeOwnerData - onSubmit: (data: ChangeOwnerData) => void -}) => { - const { safe, safeAddress } = useSafeInfo() - const { removedOwner, newOwner } = data - const owners = safe.owners - - const isReplace = Boolean(removedOwner) - - const defaultValues: OwnerData = { - address: newOwner?.address, - name: newOwner?.name, - } - - const formMethods = useForm({ - defaultValues, - mode: 'onChange', - }) - const { handleSubmit, formState, watch } = formMethods - const isValid = Object.keys(formState.errors).length === 0 // do not use formState.isValid because names can be empty - - const notAlreadyOwner = uniqueAddress(owners?.map((owner) => owner.value)) - const notCurrentSafe = addressIsNotCurrentSafe(safeAddress) - const combinedValidate = (address: string) => notAlreadyOwner(address) || notCurrentSafe(address) - - const address = watch('address') - - const { name, ens, resolving } = useAddressResolver(address) - - // Address book, ENS - const fallbackName = name || ens - - const onFormSubmit = handleSubmit((formData: OwnerData) => { - onSubmit({ - ...data, - newOwner: { - ...formData, - name: formData.name || fallbackName, - }, - }) - }) - - return ( - -
- - - {isReplace - ? 'Review the owner you want to replace in the active Safe Account, then specify the new owner you want to replace it with:' - : 'Add a new owner to the active Safe Account.'} - - - {removedOwner && ( - - Current owner - - - )} - - - New owner - - - - - ), - }} - /> - - - - - - - - - -
-
- ) -} diff --git a/src/components/settings/owner/AddOwnerDialog/DialogSteps/ReviewOwnerTxStep.tsx b/src/components/settings/owner/AddOwnerDialog/DialogSteps/ReviewOwnerTxStep.tsx deleted file mode 100644 index 835fc7986e..0000000000 --- a/src/components/settings/owner/AddOwnerDialog/DialogSteps/ReviewOwnerTxStep.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import EthHashInfo from '@/components/common/EthHashInfo' -import useSafeInfo from '@/hooks/useSafeInfo' -import { Box, Divider, Grid, Typography } from '@mui/material' -import css from './styles.module.css' -import type { ChangeOwnerData } from '@/components/settings/owner/AddOwnerDialog/DialogSteps/types' -import useAsync from '@/hooks/useAsync' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' -import { useAppDispatch } from '@/store' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import { sameAddress } from '@/utils/addresses' -import useAddressBook from '@/hooks/useAddressBook' -import React from 'react' -import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' -import { createAddOwnerTx, createSwapOwnerTx } from '@/services/tx/tx-sender' - -export const ReviewOwnerTxStep = ({ data, onSubmit }: { data: ChangeOwnerData; onSubmit: () => void }) => { - const { safe, safeAddress } = useSafeInfo() - const { chainId } = safe - const dispatch = useAppDispatch() - const addressBook = useAddressBook() - const { newOwner, removedOwner, threshold } = data - - const [safeTx, safeTxError] = useAsync(() => { - if (removedOwner) { - return createSwapOwnerTx({ - newOwnerAddress: newOwner.address, - oldOwnerAddress: removedOwner.address, - }) - } else { - return createAddOwnerTx({ - ownerAddress: newOwner.address, - threshold, - }) - } - }, [removedOwner, newOwner]) - - const isReplace = Boolean(removedOwner) - - const addAddressBookEntryAndSubmit = () => { - if (typeof newOwner.name !== 'undefined') { - dispatch( - upsertAddressBookEntry({ - chainId: chainId, - address: newOwner.address, - name: newOwner.name, - }), - ) - } - - trackEvent({ ...SETTINGS_EVENTS.SETUP.THRESHOLD, label: safe.threshold }) - trackEvent({ ...SETTINGS_EVENTS.SETUP.OWNERS, label: safe.owners.length }) - - onSubmit() - } - - return ( - - `1px solid ${palette.border.light}`} - > - - Details - - Name of the Safe Account: - - {addressBook[safeAddress] || 'No name'} - - Any transaction requires the confirmation of: - - - {threshold} out of {safe.owners.length + (isReplace ? 0 : 1)} owners - - - - [undefined, undefined, `1px solid ${palette.border.light}`]} - borderTop={({ palette }) => [`1px solid ${palette.border.light}`, undefined, 'none']} - > - {safe.owners.length} Safe Account owner(s) - - - {safe.owners - .filter((owner) => !removedOwner || !sameAddress(owner.value, removedOwner.address)) - .map((owner) => { - return ( - - ) - })} - - {removedOwner && ( - <> -
- Removing owner ↓ -
- - - - - - - )} -
- Adding new owner ↓ -
- - - - -
-
-
- ) -} diff --git a/src/components/settings/owner/AddOwnerDialog/DialogSteps/SetThresholdStep.tsx b/src/components/settings/owner/AddOwnerDialog/DialogSteps/SetThresholdStep.tsx deleted file mode 100644 index 3e5168dbb5..0000000000 --- a/src/components/settings/owner/AddOwnerDialog/DialogSteps/SetThresholdStep.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { SelectChangeEvent } from '@mui/material' -import { Button, DialogContent, Grid, MenuItem, Select, Typography } from '@mui/material' -import type { SyntheticEvent } from 'react' -import { useState } from 'react' -import type { ChangeOwnerData } from '@/components/settings/owner/AddOwnerDialog/DialogSteps/types' -import useSafeInfo from '@/hooks/useSafeInfo' - -export const SetThresholdStep = ({ - data, - onSubmit, -}: { - data: ChangeOwnerData - onSubmit: (data: ChangeOwnerData) => void -}) => { - const { safe } = useSafeInfo() - const [selectedThreshold, setSelectedThreshold] = useState(data.threshold ?? 1) - - const handleChange = (event: SelectChangeEvent) => { - setSelectedThreshold(parseInt(event.target.value.toString())) - } - - const onSubmitHandler = (e: SyntheticEvent) => { - e.preventDefault() - onSubmit({ ...data, threshold: selectedThreshold }) - } - - const newNumberOfOwners = safe.owners.length + 1 - - return ( -
- - Set the required owner confirmations: - - Any transaction requires the confirmation of: - - - - - - - out of {newNumberOfOwners} owner(s) - - - - - -
- ) -} diff --git a/src/components/settings/owner/AddOwnerDialog/DialogSteps/styles.module.css b/src/components/settings/owner/AddOwnerDialog/DialogSteps/styles.module.css deleted file mode 100644 index 7ec06ea4e1..0000000000 --- a/src/components/settings/owner/AddOwnerDialog/DialogSteps/styles.module.css +++ /dev/null @@ -1,15 +0,0 @@ -.info { - background-color: var(--color-background-main); - align-items: center; - display: flex; - flex-direction: column; - padding: var(--space-1); -} - -.overline { - font-size: 11px; - line-height: 14px; - letter-spacing: 1px; - align-self: center; - text-transform: uppercase; -} diff --git a/src/components/settings/owner/AddOwnerDialog/DialogSteps/types.d.ts b/src/components/settings/owner/AddOwnerDialog/DialogSteps/types.d.ts deleted file mode 100644 index 6608336192..0000000000 --- a/src/components/settings/owner/AddOwnerDialog/DialogSteps/types.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface ChangeOwnerData { - removedOwner?: OwnerData - newOwner: OwnerData - threshold?: number -} - -export interface OwnerData { - address: string - name?: string -} diff --git a/src/components/settings/owner/AddOwnerDialog/index.tsx b/src/components/settings/owner/AddOwnerDialog/index.tsx deleted file mode 100644 index 668c3e6891..0000000000 --- a/src/components/settings/owner/AddOwnerDialog/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Button, SvgIcon } from '@mui/material' -import { useState } from 'react' -import AddIcon from '@/public/images/common/add.svg' -import { ChooseOwnerStep } from './DialogSteps/ChooseOwnerStep' - -import TxModal from '@/components/tx/TxModal' -import useSafeInfo from '@/hooks/useSafeInfo' -import { ReviewOwnerTxStep } from '@/components/settings/owner/AddOwnerDialog/DialogSteps/ReviewOwnerTxStep' -import type { ChangeOwnerData } from '@/components/settings/owner/AddOwnerDialog/DialogSteps/types' -import { SetThresholdStep } from '@/components/settings/owner/AddOwnerDialog/DialogSteps/SetThresholdStep' -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import Box from '@mui/material/Box' -import Track from '@/components/common/Track' -import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' -import CheckWallet from '@/components/common/CheckWallet' - -const AddOwnerSteps: TxStepperProps['steps'] = [ - { - label: 'Add new owner', - render: (data, onSubmit) => , - }, - { - label: 'Set threshold', - render: (data, onSubmit) => , - }, - { - label: 'Review transaction', - render: (data, onSubmit) => , - }, -] - -export const AddOwnerDialog = () => { - const [open, setOpen] = useState(false) - - const { safe } = useSafeInfo() - - const handleClose = () => setOpen(false) - - const initialModalData: Partial = { threshold: safe.threshold } - - return ( - - - {(isOk) => ( - - - - )} - - - {open && } - - ) -} diff --git a/src/components/settings/owner/ChangeThresholdDialog/index.tsx b/src/components/settings/owner/ChangeThresholdDialog/index.tsx deleted file mode 100644 index 84d11659f4..0000000000 --- a/src/components/settings/owner/ChangeThresholdDialog/index.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import type { SelectChangeEvent } from '@mui/material' -import { Box, Button, DialogContent, Grid, MenuItem, Select, Typography } from '@mui/material' -import { useState } from 'react' - -import TxModal from '@/components/tx/TxModal' -import useSafeInfo from '@/hooks/useSafeInfo' - -import useAsync from '@/hooks/useAsync' - -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import Track from '@/components/common/Track' -import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' -import { createUpdateThresholdTx } from '@/services/tx/tx-sender' -import CheckWallet from '@/components/common/CheckWallet' - -interface ChangeThresholdData { - threshold: number -} - -const ChangeThresholdSteps: TxStepperProps['steps'] = [ - { - label: 'Change threshold', - render: (data, onSubmit) => , - }, -] - -export const ChangeThresholdDialog = () => { - const [open, setOpen] = useState(false) - - const { safe } = useSafeInfo() - - const handleClose = () => setOpen(false) - - const initialModalData: ChangeThresholdData = { threshold: safe.threshold || 1 } - - return ( - - - {(isOk) => ( - - - - )} - - - {open && } - - ) -} - -const ChangeThresholdStep = ({ data, onSubmit }: { data: ChangeThresholdData; onSubmit: () => void }) => { - const { safe } = useSafeInfo() - const [selectedThreshold, setSelectedThreshold] = useState(safe.threshold) - const [isChanged, setChanged] = useState(false) - const isSameThreshold = selectedThreshold === safe.threshold - - const handleChange = (event: SelectChangeEvent) => { - const newThreshold = parseInt(event.target.value.toString()) - setSelectedThreshold(newThreshold) - setChanged(true) - } - - const [safeTx, safeTxError] = useAsync(() => { - if (!selectedThreshold) return - - return createUpdateThresholdTx(selectedThreshold) - }, [selectedThreshold]) - - const onChangeThreshold = () => { - trackEvent({ ...SETTINGS_EVENTS.SETUP.OWNERS, label: safe.owners.length }) - trackEvent({ ...SETTINGS_EVENTS.SETUP.THRESHOLD, label: selectedThreshold }) - - onSubmit() - } - - return ( - <> - - Any transaction will require the confirmation of: - - - - - - - - out of {safe.owners.length} owner(s) - - - - {isChanged && isSameThreshold ? ( - - Current policy is already set to {data.threshold} - - ) : ( - - {isChanged ? 'Previous policy was ' : 'Current policy is '} - - {data.threshold} out of {safe.owners.length} - - . - - )} - - - - - - - ) -} diff --git a/src/components/settings/owner/OwnerList/index.tsx b/src/components/settings/owner/OwnerList/index.tsx index 512b084e0d..01532c1f20 100644 --- a/src/components/settings/owner/OwnerList/index.tsx +++ b/src/components/settings/owner/OwnerList/index.tsx @@ -1,13 +1,20 @@ import EthHashInfo from '@/components/common/EthHashInfo' -import { AddOwnerDialog } from '@/components/settings/owner/AddOwnerDialog' +import AddOwnerFlow from '@/components/tx-flow/flows/AddOwner' import useAddressBook from '@/hooks/useAddressBook' import useSafeInfo from '@/hooks/useSafeInfo' -import { Box, Grid, Typography } from '@mui/material' -import { useMemo } from 'react' +import { Box, Grid, Typography, Button, SvgIcon, Tooltip, IconButton } from '@mui/material' +import { useContext, useMemo } from 'react' import { EditOwnerDialog } from '../EditOwnerDialog' -import { RemoveOwnerDialog } from '../RemoveOwnerDialog' -import { ReplaceOwnerDialog } from '../ReplaceOwnerDialog' +import ReplaceOwnerFlow from '@/components/tx-flow/flows/ReplaceOwner' +import RemoveOwnerFlow from '@/components/tx-flow/flows/RemoveOwner' import EnhancedTable from '@/components/common/EnhancedTable' +import AddIcon from '@/public/images/common/add.svg' +import Track from '@/components/common/Track' +import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' +import CheckWallet from '@/components/common/CheckWallet' +import { TxModalContext } from '@/components/tx-flow' +import ReplaceOwnerIcon from '@/public/images/settings/setup/replace-owner.svg' +import DeleteIcon from '@/public/images/common/delete.svg' import tableCss from '@/components/common/EnhancedTable/styles.module.css' @@ -19,8 +26,11 @@ const headCells = [ export const OwnerList = () => { const addressBook = useAddressBook() const { safe } = useSafeInfo() + const { setTxFlow } = useContext(TxModalContext) const rows = useMemo(() => { + const showRemoveOwnerButton = safe.owners.length > 1 + return safe.owners.map((owner) => { const address = owner.value const name = addressBook[address] @@ -36,16 +46,48 @@ export const OwnerList = () => { sticky: true, content: (
- + + {(isOk) => ( + + + setTxFlow()} + size="small" + disabled={!isOk} + > + + + + + )} + + - + + {showRemoveOwnerButton && ( + + {(isOk) => ( + + + setTxFlow()} + size="small" + disabled={!isOk} + > + + + + + )} + + )}
), }, }, } }) - }, [safe, addressBook]) + }, [safe.owners, safe.chainId, addressBook, setTxFlow]) return ( @@ -63,7 +105,23 @@ export const OwnerList = () => { - + + + + {(isOk) => ( + + + + )} + + diff --git a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewRemoveOwnerTxStep.tsx b/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewRemoveOwnerTxStep.tsx deleted file mode 100644 index b152ff6fef..0000000000 --- a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewRemoveOwnerTxStep.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import EthHashInfo from '@/components/common/EthHashInfo' -import useSafeInfo from '@/hooks/useSafeInfo' -import { Box, Divider, Grid, Typography } from '@mui/material' -import css from './styles.module.css' -import useAsync from '@/hooks/useAsync' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import { sameAddress } from '@/utils/addresses' -import useAddressBook from '@/hooks/useAddressBook' -import type { RemoveOwnerData } from '..' -import React from 'react' -import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' -import { createRemoveOwnerTx } from '@/services/tx/tx-sender' - -export const ReviewRemoveOwnerTxStep = ({ data, onSubmit }: { data: RemoveOwnerData; onSubmit: () => void }) => { - const { safe, safeAddress } = useSafeInfo() - const addressBook = useAddressBook() - const { removedOwner, threshold } = data - - const [safeTx, safeTxError] = useAsync(async () => { - return createRemoveOwnerTx({ ownerAddress: removedOwner.address, threshold }) - }, [removedOwner.address, threshold]) - - const newOwnerLength = safe.owners.length - 1 - - const onFormSubmit = () => { - trackEvent({ ...SETTINGS_EVENTS.SETUP.THRESHOLD, label: safe.threshold }) - trackEvent({ ...SETTINGS_EVENTS.SETUP.OWNERS, label: safe.owners.length }) - - onSubmit() - } - - return ( - - `1px solid ${palette.border.light}`} - > - - Details - - Name of the Safe Account: - - {addressBook[safeAddress] || 'No name'} - - Any transaction requires the confirmation of: - - - {threshold} out of {newOwnerLength} owners - - - - [undefined, undefined, `1px solid ${palette.border.light}`]} - borderTop={({ palette }) => [`1px solid ${palette.border.light}`, undefined, 'none']} - > - {newOwnerLength} Safe Account owner(s) - - {safe.owners - .filter((owner) => !sameAddress(owner.value, removedOwner.address)) - .map((owner) => ( -
- - - - -
- ))} - { - <> -
- Removing owner ↓ -
- - - - - - - } -
-
-
- ) -} diff --git a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewSelectedOwnerStep.tsx b/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewSelectedOwnerStep.tsx deleted file mode 100644 index 2fed2df8db..0000000000 --- a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewSelectedOwnerStep.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import EthHashInfo from '@/components/common/EthHashInfo' -import { Button, DialogContent, Typography } from '@mui/material' -import type { RemoveOwnerData } from '..' - -export const ReviewSelectedOwnerStep = ({ - data, - onSubmit, -}: { - data: RemoveOwnerData - onSubmit: (data: RemoveOwnerData) => void -}) => { - return ( -
onSubmit(data)}> - - Review the owner you want to remove from the active Safe Account: - - - -
- ) -} diff --git a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/SetThresholdStep.tsx b/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/SetThresholdStep.tsx deleted file mode 100644 index fdad1591d7..0000000000 --- a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/SetThresholdStep.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { SelectChangeEvent } from '@mui/material' -import { Button, DialogContent, Grid, MenuItem, Select, Typography } from '@mui/material' -import type { SyntheticEvent } from 'react' -import { useState } from 'react' -import useSafeInfo from '@/hooks/useSafeInfo' -import type { RemoveOwnerData } from '..' - -export const SetThresholdStep = ({ - data, - onSubmit, -}: { - data: RemoveOwnerData - onSubmit: (data: RemoveOwnerData) => void -}) => { - const { safe } = useSafeInfo() - const [selectedThreshold, setSelectedThreshold] = useState(data.threshold ?? 1) - - const handleChange = (event: SelectChangeEvent) => { - setSelectedThreshold(parseInt(event.target.value.toString())) - } - - const onSubmitHandler = (e: SyntheticEvent) => { - e.preventDefault() - onSubmit({ ...data, threshold: selectedThreshold }) - } - - const newNumberOfOwners = safe ? safe.owners.length - 1 : 1 - - return ( -
- - Set the required owner confirmations: - - Any transaction requires the confirmation of: - - - - - - - out of {newNumberOfOwners} owner(s) - - - - - -
- ) -} diff --git a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/styles.module.css b/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/styles.module.css deleted file mode 100644 index d3772b38d0..0000000000 --- a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/styles.module.css +++ /dev/null @@ -1,7 +0,0 @@ -.info { - background-color: var(--color-background-main); - align-items: center; - display: flex; - flex-direction: column; - padding: var(--space-1); -} diff --git a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/types.d.ts b/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/types.d.ts deleted file mode 100644 index 6608336192..0000000000 --- a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/types.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface ChangeOwnerData { - removedOwner?: OwnerData - newOwner: OwnerData - threshold?: number -} - -export interface OwnerData { - address: string - name?: string -} diff --git a/src/components/settings/owner/RemoveOwnerDialog/index.tsx b/src/components/settings/owner/RemoveOwnerDialog/index.tsx deleted file mode 100644 index 00082e45ca..0000000000 --- a/src/components/settings/owner/RemoveOwnerDialog/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { IconButton, Tooltip, SvgIcon } from '@mui/material' -import { useState } from 'react' -import DeleteIcon from '@/public/images/common/delete.svg' -import TxModal from '@/components/tx/TxModal' -import useSafeInfo from '@/hooks/useSafeInfo' -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import type { OwnerData } from './DialogSteps/types' -import { ReviewSelectedOwnerStep } from './DialogSteps/ReviewSelectedOwnerStep' -import { SetThresholdStep } from './DialogSteps/SetThresholdStep' -import { ReviewRemoveOwnerTxStep } from './DialogSteps/ReviewRemoveOwnerTxStep' -import Track from '@/components/common/Track' -import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' -import CheckWallet from '@/components/common/CheckWallet' - -export type RemoveOwnerData = { - removedOwner: OwnerData - threshold: number -} - -const RemoveOwnerSteps: TxStepperProps['steps'] = [ - { - label: 'Remove owner', - render: (data, onSubmit) => , - }, - { - label: 'Set threshold', - render: (data, onSubmit) => , - }, - { - label: 'Review transaction', - render: (data, onSubmit) => , - }, -] - -export const RemoveOwnerDialog = ({ owner }: { owner: OwnerData }) => { - const [open, setOpen] = useState(false) - - const { safe } = useSafeInfo() - - const handleClose = () => setOpen(false) - - const showRemoveOwnerButton = safe.owners.length > 1 - - if (!showRemoveOwnerButton) { - return null - } - - const initialModalData: RemoveOwnerData = { - threshold: Math.min(safe.threshold, safe.owners.length - 1), - removedOwner: owner, - } - - return ( -
- - {(isOk) => ( - - - setOpen(true)} size="small" disabled={!isOk}> - - - - - )} - - - {open && } -
- ) -} diff --git a/src/components/settings/owner/ReplaceOwnerDialog/index.tsx b/src/components/settings/owner/ReplaceOwnerDialog/index.tsx deleted file mode 100644 index 1cda1147ff..0000000000 --- a/src/components/settings/owner/ReplaceOwnerDialog/index.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { IconButton, Tooltip, SvgIcon } from '@mui/material' -import { useState } from 'react' -import { ChooseOwnerStep } from '../AddOwnerDialog/DialogSteps/ChooseOwnerStep' - -import TxModal from '@/components/tx/TxModal' -import useSafeInfo from '@/hooks/useSafeInfo' -import { ReviewOwnerTxStep } from '@/components/settings/owner/AddOwnerDialog/DialogSteps/ReviewOwnerTxStep' -import type { ChangeOwnerData } from '@/components/settings/owner/AddOwnerDialog/DialogSteps/types' -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import Track from '@/components/common/Track' -import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' -import ReplaceOwnerIcon from '@/public/images/settings/setup/replace-owner.svg' -import CheckWallet from '@/components/common/CheckWallet' - -const ReplaceOwnerSteps: TxStepperProps['steps'] = [ - { - label: 'Replace owner', - render: (data, onSubmit) => , - }, - { - label: 'Review transaction', - render: (data, onSubmit) => , - }, -] - -export const ReplaceOwnerDialog = ({ address }: { address: string }) => { - const [open, setOpen] = useState(false) - - const handleClose = () => setOpen(false) - - const { safe } = useSafeInfo() - - const initialModalData: Partial = { - removedOwner: { address }, - threshold: safe.threshold, - } - - return ( -
- - {(isOk) => ( - - - setOpen(true)} size="small" disabled={!isOk}> - - - - - )} - - - {open && } -
- ) -} diff --git a/src/components/sidebar/NewTxButton/index.tsx b/src/components/sidebar/NewTxButton/index.tsx index eb7659d703..7bd49a6b4a 100644 --- a/src/components/sidebar/NewTxButton/index.tsx +++ b/src/components/sidebar/NewTxButton/index.tsx @@ -1,45 +1,35 @@ -import { Suspense, useState, type ReactElement } from 'react' -import dynamic from 'next/dynamic' +import { type ReactElement, useContext } from 'react' import Button from '@mui/material/Button' import css from './styles.module.css' -import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' +import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import CheckWallet from '@/components/common/CheckWallet' - -const NewTxModal = dynamic(() => import('@/components/tx/modals/NewTxModal')) +import { TxModalContext } from '@/components/tx-flow' +import NewTxMenu from '@/components/tx-flow/flows/NewTx' const NewTxButton = (): ReactElement => { - const [txOpen, setTxOpen] = useState(false) + const { setTxFlow } = useContext(TxModalContext) const onClick = () => { - setTxOpen(true) - + setTxFlow(, undefined, false) trackEvent(OVERVIEW_EVENTS.NEW_TRANSACTION) } return ( - <> - - {(isOk) => ( - - )} - - - {txOpen && ( - - setTxOpen(false)} /> - + + {(isOk) => ( + )} - + ) } diff --git a/src/components/sidebar/SafeList/styles.module.css b/src/components/sidebar/SafeList/styles.module.css index 2074c89fee..275ee9c000 100644 --- a/src/components/sidebar/SafeList/styles.module.css +++ b/src/components/sidebar/SafeList/styles.module.css @@ -37,7 +37,7 @@ padding: 0 0; } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .list { overflow-x: auto; } diff --git a/src/components/sidebar/Sidebar/styles.module.css b/src/components/sidebar/Sidebar/styles.module.css index f6d1be7b18..08b9dff4c3 100644 --- a/src/components/sidebar/Sidebar/styles.module.css +++ b/src/components/sidebar/Sidebar/styles.module.css @@ -64,7 +64,7 @@ transform: translateX(-25%); } -@media (max-width: 900px) { +@media (max-width: 899.95px) { .container { padding-top: var(--header-height); } diff --git a/src/components/transactions/BatchExecuteButton/index.tsx b/src/components/transactions/BatchExecuteButton/index.tsx index 4cb0a891a0..301fb0b9b3 100644 --- a/src/components/transactions/BatchExecuteButton/index.tsx +++ b/src/components/transactions/BatchExecuteButton/index.tsx @@ -1,17 +1,18 @@ -import { useCallback, useContext, useState } from 'react' +import { useCallback, useContext } from 'react' import { Button, Tooltip } from '@mui/material' import { BatchExecuteHoverContext } from '@/components/transactions/BatchExecuteButton/BatchExecuteHoverProvider' import { useAppSelector } from '@/store' import { selectPendingTxs } from '@/store/pendingTxsSlice' import useBatchedTxs from '@/hooks/useBatchedTxs' -import BatchExecuteModal from '@/components/tx/modals/BatchExecuteModal' +import ExecuteBatchFlow from '@/components/tx-flow/flows/ExecuteBatch' import { trackEvent } from '@/services/analytics' import { TX_LIST_EVENTS } from '@/services/analytics/events/txList' import useWallet from '@/hooks/wallets/useWallet' import useTxQueue from '@/hooks/useTxQueue' +import { TxModalContext } from '@/components/tx-flow' const BatchExecuteButton = () => { - const [open, setOpen] = useState(false) + const { setTxFlow } = useContext(TxModalContext) const pendingTxs = useAppSelector(selectPendingTxs) const hoverContext = useContext(BatchExecuteHoverContext) const { page } = useTxQueue() @@ -36,7 +37,7 @@ const BatchExecuteButton = () => { label: batchableTransactions.length, }) - setOpen(true) + setTxFlow(, undefined, false) } return ( @@ -63,7 +64,6 @@ const BatchExecuteButton = () => { - {open && setOpen(false)} initialData={[{ txs: batchableTransactions }]} />} ) } diff --git a/src/components/transactions/ExecuteTxButton/index.tsx b/src/components/transactions/ExecuteTxButton/index.tsx index 302310b933..1cc4d8e60a 100644 --- a/src/components/transactions/ExecuteTxButton/index.tsx +++ b/src/components/transactions/ExecuteTxButton/index.tsx @@ -1,11 +1,10 @@ import type { SyntheticEvent } from 'react' -import { useState, type ReactElement, useContext } from 'react' +import { type ReactElement, useContext } from 'react' import { type TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' import { Button, Tooltip, SvgIcon } from '@mui/material' import useSafeInfo from '@/hooks/useSafeInfo' import { isMultisigExecutionInfo } from '@/utils/transaction-guards' -import ExecuteTxModal from '@/components/tx/modals/ExecuteTxModal' import useIsPending from '@/hooks/useIsPending' import RocketIcon from '@/public/images/transactions/rocket.svg' import IconButton from '@mui/material/IconButton' @@ -15,6 +14,8 @@ import { ReplaceTxHoverContext } from '../GroupedTxListItems/ReplaceTxHoverProvi import CheckWallet from '@/components/common/CheckWallet' import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' import { getTxButtonTooltip } from '@/components/transactions/utils' +import { TxModalContext } from '@/components/tx-flow' +import ConfirmTxFlow from '@/components/tx-flow/flows/ConfirmTx' const ExecuteTxButton = ({ txSummary, @@ -23,7 +24,7 @@ const ExecuteTxButton = ({ txSummary: TransactionSummary compact?: boolean }): ReactElement => { - const [open, setOpen] = useState(false) + const { setTxFlow } = useContext(TxModalContext) const { safe } = useSafeInfo() const txNonce = isMultisigExecutionInfo(txSummary.executionInfo) ? txSummary.executionInfo.nonce : undefined const isPending = useIsPending(txSummary.id) @@ -37,7 +38,7 @@ const ExecuteTxButton = ({ const onClick = (e: SyntheticEvent) => { e.stopPropagation() - setOpen(true) + setTxFlow(, undefined, false) } const onMouseEnter = () => { @@ -83,8 +84,6 @@ const ExecuteTxButton = ({ )} - - {open && setOpen(false)} initialData={[txSummary]} />} ) } diff --git a/src/components/transactions/GroupedTxListItems/styles.module.css b/src/components/transactions/GroupedTxListItems/styles.module.css index 6fc69fbf51..e9581dfbb2 100644 --- a/src/components/transactions/GroupedTxListItems/styles.module.css +++ b/src/components/transactions/GroupedTxListItems/styles.module.css @@ -31,7 +31,7 @@ text-decoration: line-through; } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .disclaimerContainer { gap: var(--space-1); align-items: flex-start; diff --git a/src/components/transactions/RejectTxButton/index.tsx b/src/components/transactions/RejectTxButton/index.tsx index 88590cf96e..33289fb1a1 100644 --- a/src/components/transactions/RejectTxButton/index.tsx +++ b/src/components/transactions/RejectTxButton/index.tsx @@ -1,10 +1,9 @@ import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' -import { Button, Tooltip, SvgIcon } from '@mui/material' +import { Button, SvgIcon, Tooltip } from '@mui/material' -import type { SyntheticEvent, ReactElement } from 'react' -import { useState, Suspense } from 'react' +import type { ReactElement } from 'react' +import { useContext } from 'react' import { isMultisigExecutionInfo } from '@/utils/transaction-guards' -import dynamic from 'next/dynamic' import useIsPending from '@/hooks/useIsPending' import IconButton from '@mui/material/IconButton' import ErrorIcon from '@/public/images/notifications/error.svg' @@ -13,8 +12,8 @@ import { TX_LIST_EVENTS } from '@/services/analytics/events/txList' import CheckWallet from '@/components/common/CheckWallet' import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' import { getTxButtonTooltip } from '@/components/transactions/utils' - -const NewTxModal = dynamic(() => import('@/components/tx/modals/NewTxModal')) +import { TxModalContext } from '@/components/tx-flow' +import ReplaceTxMenu from '@/components/tx-flow/flows/ReplaceTx' const RejectTxButton = ({ txSummary, @@ -23,7 +22,8 @@ const RejectTxButton = ({ txSummary: TransactionSummary compact?: boolean }): ReactElement | null => { - const [open, setOpen] = useState(false) + const { setTxFlow } = useContext(TxModalContext) + const txNonce = isMultisigExecutionInfo(txSummary.executionInfo) ? txSummary.executionInfo.nonce : undefined const isPending = useIsPending(txSummary.id) const safeSDK = useSafeSDK() @@ -31,39 +31,31 @@ const RejectTxButton = ({ const tooltipTitle = getTxButtonTooltip('Replace', { hasSafeSDK: !!safeSDK }) - const onClick = (e: SyntheticEvent) => { - e.stopPropagation() - setOpen(true) + const openReplacementModal = () => { + if (!txNonce) return + setTxFlow() } return ( - <> - - {(isOk) => ( - - {compact ? ( - - - - - - - - ) : ( - - )} - - )} - - - {open && ( - - setOpen(false)} txNonce={txNonce} /> - + + {(isOk) => ( + + {compact ? ( + + + + + + + + ) : ( + + )} + )} - + ) } diff --git a/src/components/transactions/SignTxButton/index.tsx b/src/components/transactions/SignTxButton/index.tsx index 2c0b3b4c0f..fbd35d7901 100644 --- a/src/components/transactions/SignTxButton/index.tsx +++ b/src/components/transactions/SignTxButton/index.tsx @@ -1,11 +1,10 @@ import type { SyntheticEvent } from 'react' -import { useState, type ReactElement } from 'react' +import { useContext, type ReactElement } from 'react' import { type TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' import { Button, Tooltip } from '@mui/material' import { isSignableBy } from '@/utils/transaction-guards' import useWallet from '@/hooks/wallets/useWallet' -import ConfirmTxModal from '@/components/tx/modals/ConfirmTxModal' import useIsPending from '@/hooks/useIsPending' import IconButton from '@mui/material/IconButton' import CheckIcon from '@mui/icons-material/Check' @@ -14,6 +13,8 @@ import { TX_LIST_EVENTS } from '@/services/analytics/events/txList' import CheckWallet from '@/components/common/CheckWallet' import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' import { getTxButtonTooltip } from '@/components/transactions/utils' +import { TxModalContext } from '@/components/tx-flow' +import ConfirmTxFlow from '@/components/tx-flow/flows/ConfirmTx' const SignTxButton = ({ txSummary, @@ -22,7 +23,7 @@ const SignTxButton = ({ txSummary: TransactionSummary compact?: boolean }): ReactElement => { - const [open, setOpen] = useState(false) + const { setTxFlow } = useContext(TxModalContext) const wallet = useWallet() const isSignable = isSignableBy(txSummary, wallet?.address || '') const isPending = useIsPending(txSummary.id) @@ -34,33 +35,29 @@ const SignTxButton = ({ const onClick = (e: SyntheticEvent) => { e.stopPropagation() - setOpen(true) + setTxFlow(, undefined, false) } return ( - <> - - {(isOk) => ( - - {compact ? ( - - - - - - - - ) : ( - - )} - - )} - - - {open && setOpen(false)} initialData={[txSummary]} />} - + + {(isOk) => ( + + {compact ? ( + + + + + + + + ) : ( + + )} + + )} + ) } diff --git a/src/components/transactions/TxDetails/SafeTxGasForm.tsx b/src/components/transactions/TxDetails/SafeTxGasForm.tsx new file mode 100644 index 0000000000..5cbae524ca --- /dev/null +++ b/src/components/transactions/TxDetails/SafeTxGasForm.tsx @@ -0,0 +1,79 @@ +import { useContext, useState } from 'react' +import { Link, Box, Paper, Button } from '@mui/material' +import { useForm } from 'react-hook-form' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import NumberField from '@/components/common/NumberField' +import useSafeInfo from '@/hooks/useSafeInfo' +import { isLegacyVersion } from '@/hooks/coreSDK/safeCoreSDK' + +type FormFields = { + safeTxGas: number +} + +const Form = ({ onSubmit }: { onSubmit: () => void }) => { + const { safeTxGas = 0, setSafeTxGas } = useContext(SafeTxContext) + + const formMethods = useForm({ + defaultValues: { + safeTxGas, + }, + mode: 'onChange', + }) + + const onFormSubmit = (values: FormFields) => { + setSafeTxGas(values.safeTxGas || 0) + onSubmit() + } + + // Close the form w/o submitting if the user clicks outside of it + const onBlur = () => { + setTimeout(onSubmit, 100) + } + + return ( + +
+ + + +
+ ) +} + +const SafeTxGasForm = () => { + const { safeTx, safeTxGas = 0 } = useContext(SafeTxContext) + const { safe } = useSafeInfo() + const isOldSafe = safe.version && isLegacyVersion(safe.version) + const isEditable = safeTx?.signatures.size === 0 && (safeTxGas > 0 || isOldSafe) + const [editing, setEditing] = useState(false) + + return ( + + {safeTxGas} + + {isEditable && ( + setEditing(true)} fontSize="small"> + Edit + + )} + + {editing &&
setEditing(false)} />} + + ) +} + +export default SafeTxGasForm diff --git a/src/components/transactions/TxDetails/Summary/TxDataRow/index.tsx b/src/components/transactions/TxDetails/Summary/TxDataRow/index.tsx index b5361ccb57..893dc08503 100644 --- a/src/components/transactions/TxDetails/Summary/TxDataRow/index.tsx +++ b/src/components/transactions/TxDetails/Summary/TxDataRow/index.tsx @@ -4,6 +4,7 @@ import { Typography } from '@mui/material' import { hexDataLength } from 'ethers/lib/utils' import type { ReactElement, ReactNode } from 'react' import css from './styles.module.css' +import valueCss from '@/components/transactions/TxDetails/TxData/DecodedData/ValueArray/styles.module.css' import EthHashInfo from '@/components/common/EthHashInfo' type TxDataRowProps = { @@ -31,7 +32,11 @@ export const generateDataRowValue = ( switch (type) { case 'hash': case 'address': - return + return ( +
+ +
+ ) case 'rawData': return (
diff --git a/src/components/transactions/TxDetails/Summary/TxDataRow/styles.module.css b/src/components/transactions/TxDetails/Summary/TxDataRow/styles.module.css index b27dff0d8c..91bad958c2 100644 --- a/src/components/transactions/TxDetails/Summary/TxDataRow/styles.module.css +++ b/src/components/transactions/TxDetails/Summary/TxDataRow/styles.module.css @@ -26,7 +26,7 @@ align-items: center; } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .gridRow { grid-template-columns: 1fr; gap: 0; diff --git a/src/components/transactions/TxDetails/Summary/index.tsx b/src/components/transactions/TxDetails/Summary/index.tsx index ff39c72872..24eb2a0ef4 100644 --- a/src/components/transactions/TxDetails/Summary/index.tsx +++ b/src/components/transactions/TxDetails/Summary/index.tsx @@ -7,6 +7,8 @@ import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sd import { Operation } from '@safe-global/safe-gateway-typescript-sdk' import { dateString } from '@/utils/formatters' import css from './styles.module.css' +import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import SafeTxGasForm from '../SafeTxGasForm' interface Props { txDetails: TransactionDetails @@ -69,3 +71,17 @@ const Summary = ({ txDetails, defaultExpanded = false }: Props): ReactElement => } export default Summary + +export const PartialSummary = ({ safeTx }: { safeTx: SafeTransaction }) => { + const txData = safeTx.data + return ( + <> + + + + {txData.baseGas} + {generateDataRowValue(txData.refundReceiver, 'hash', true)} + {generateDataRowValue(txData.data, 'rawData')} + + ) +} diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx b/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx index b592b371c6..892cfebb15 100644 --- a/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx +++ b/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx @@ -5,8 +5,9 @@ import { useState, useEffect } from 'react' import type { Dispatch, ReactElement, SetStateAction } from 'react' import type { AccordionProps } from '@mui/material/Accordion/Accordion' import SingleTxDecoded from '@/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded' -import { Box, Button, Divider, Stack } from '@mui/material' +import { Button, Divider, Stack } from '@mui/material' import css from './styles.module.css' +import classnames from 'classnames' type MultisendProps = { txData?: TransactionData @@ -14,20 +15,24 @@ type MultisendProps = { compact?: boolean } -const MultisendActionsHeader = ({ +export const MultisendActionsHeader = ({ setOpen, amount, + compact = false, + title = 'All actions', }: { setOpen: Dispatch | undefined>> amount: number + compact?: boolean + title?: string }) => { const onClickAll = (expanded: boolean) => () => { setOpen(Array(amount).fill(expanded)) } return ( -
- All actions +
+ {title} }> + + ) +} + +export const SendNFTsButton = () => { + const router = useRouter() + + return ( + + + + + + ) +} + +export const TxBuilderButton = () => { + const txBuilder = useTxBuilderApp() + if (!txBuilder?.app) return null + + return ( + + + + + + + + ) +} diff --git a/src/components/tx-flow/common/TxCard/index.tsx b/src/components/tx-flow/common/TxCard/index.tsx new file mode 100644 index 0000000000..85ec9aa00b --- /dev/null +++ b/src/components/tx-flow/common/TxCard/index.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react' +import { Card, CardContent } from '@mui/material' +import css from '../styles.module.css' + +const sx = { my: 2, border: 0 } + +const TxCard = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ) +} + +export default TxCard diff --git a/src/components/tx-flow/common/TxLayout/index.tsx b/src/components/tx-flow/common/TxLayout/index.tsx new file mode 100644 index 0000000000..652f633055 --- /dev/null +++ b/src/components/tx-flow/common/TxLayout/index.tsx @@ -0,0 +1,162 @@ +import { type ComponentType, type ReactElement, type ReactNode, useContext, useEffect, useState } from 'react' +import { Box, Container, Grid, Typography, Button, Paper, SvgIcon, IconButton, useMediaQuery } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' +import classnames from 'classnames' +import { ProgressBar } from '@/components/common/ProgressBar' +import SafeTxProvider, { SafeTxContext } from '../../SafeTxProvider' +import { TxInfoProvider } from '@/components/tx-flow/TxInfoProvider' +import TxNonce from '../TxNonce' +import TxStatusWidget from '../TxStatusWidget' +import css from './styles.module.css' +import { TxSimulationMessage } from '@/components/tx/security/tenderly' +import SafeLogo from '@/public/images/logo-no-text.svg' +import { RedefineMessage } from '@/components/tx/security/redefine' +import { TxSecurityProvider } from '@/components/tx/security/shared/TxSecurityContext' +import ChainIndicator from '@/components/common/ChainIndicator' + +const TxLayoutHeader = ({ + hideNonce, + icon, + subtitle, +}: { + hideNonce: TxLayoutProps['hideNonce'] + icon: TxLayoutProps['icon'] + subtitle: TxLayoutProps['subtitle'] +}) => { + const { nonceNeeded } = useContext(SafeTxContext) + + if (hideNonce && !icon && !subtitle) return null + + return ( + + + {icon && ( +
+ +
+ )} + + + {subtitle} + +
+ + {!hideNonce && nonceNeeded && } +
+ ) +} + +type TxLayoutProps = { + title: ReactNode + children: ReactNode + subtitle?: ReactNode + icon?: ComponentType + step?: number + txSummary?: TransactionSummary + onBack?: () => void + hideNonce?: boolean + isBatch?: boolean + isReplacement?: boolean +} + +const TxLayout = ({ + title, + subtitle, + icon, + children, + step = 0, + txSummary, + onBack, + hideNonce = false, + isBatch = false, + isReplacement = false, +}: TxLayoutProps): ReactElement => { + const [statusVisible, setStatusVisible] = useState(true) + + const theme = useTheme() + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')) + + const steps = Array.isArray(children) ? children : [children] + const progress = Math.round(((step + 1) / steps.length) * 100) + + useEffect(() => { + setStatusVisible(!isSmallScreen) + }, [isSmallScreen]) + + const toggleStatus = () => { + setStatusVisible((prev) => !prev) + } + + return ( + + + + + + + + + {title} + + + + + + + + + + + + + + + + + + + +
+ {steps[step]} + + {onBack && step > 0 && ( + + )} +
+
+ + + {statusVisible && ( + setStatusVisible(false)} + isReplacement={isReplacement} + isBatch={isBatch} + /> + )} + + + + + + + +
+
+
+
+
+
+ ) +} + +export default TxLayout diff --git a/src/components/tx-flow/common/TxLayout/styles.module.css b/src/components/tx-flow/common/TxLayout/styles.module.css new file mode 100644 index 0000000000..504c07542d --- /dev/null +++ b/src/components/tx-flow/common/TxLayout/styles.module.css @@ -0,0 +1,152 @@ +.container { + margin-top: 42px; +} + +.header { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.headerInner { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-3); + border-bottom: 1px solid var(--color-border-light); +} + +.step { + position: relative; +} + +/* Back button */ +.backButton { + position: absolute; + left: var(--space-3); + bottom: var(--space-3); +} + +.step :global(.MuiCard-root:first-child) { + border-top-right-radius: 0; + border-top-left-radius: 0; + margin-top: 0; +} + +/* Submit button */ +.step :global(.MuiCardActions-root) { + display: flex; + flex-direction: column; + padding: 0; + margin-top: var(--space-3); +} + +.step :global(.MuiCardActions-root) > * { + align-self: flex-end; +} + +.icon { + width: 32px; + height: 32px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + border-radius: 6px; + border: 1px solid var(--color-border-light); + margin-right: var(--space-2); +} + +.icon svg { + height: 16px; + width: auto; +} + +.step :global(.MuiAccordionSummary-content), +.step :global(.MuiAccordionSummary-content) p { + font-weight: bold; + font-size: 14px; +} + +.step :global(.MuiAccordionSummary-expandIconWrapper) { + margin-left: var(--space-2); +} + +.statusButton { + position: absolute; + top: 0; + right: 57px; + color: var(--color-text-primary); + padding: var(--space-2); + border-left: 1px solid var(--color-border-light); + border-right: 1px solid var(--color-border-light); + border-radius: 0; + width: 24px; + height: 24px; + box-sizing: content-box; + display: none; +} + +.sticky { + display: flex; + flex-direction: column; + gap: var(--space-2); + position: sticky; + top: var(--space-2); + margin-top: var(--space-2); +} + +.titleWrapper { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-2); +} + +@media (max-width: 899.95px) { + .widget { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + } + + .widget.active { + z-index: 1; + } + + .widget :global .MuiPaper-root { + height: 100%; + } + + .titleWrapper { + position: absolute; + top: 12px; + left: var(--space-2); + margin-bottom: 0; + width: calc(100% - 145px); + } + + .title { + font-size: 16px; + } + + .container { + padding: 0; + } + + .progressBar { + display: none; + } + + .step :global(.MuiCard-root), + .header { + border-radius: 0; + } + + .statusButton { + display: inline-flex; + } +} diff --git a/src/components/tx-flow/common/TxNonce/index.tsx b/src/components/tx-flow/common/TxNonce/index.tsx new file mode 100644 index 0000000000..378afc43c5 --- /dev/null +++ b/src/components/tx-flow/common/TxNonce/index.tsx @@ -0,0 +1,214 @@ +import { memo, type ReactElement, useContext, useMemo } from 'react' +import { + Autocomplete, + Box, + IconButton, + InputAdornment, + Skeleton, + Tooltip, + Popper, + type PopperProps, + type MenuItemProps, + MenuItem, + Typography, +} from '@mui/material' +import { Controller, useForm } from 'react-hook-form' + +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import RotateLeftIcon from '@mui/icons-material/RotateLeft' +import NumberField from '@/components/common/NumberField' +import { useQueuedTxByNonce } from '@/hooks/useTxQueue' +import useSafeInfo from '@/hooks/useSafeInfo' +import useAddressBook from '@/hooks/useAddressBook' +import { getLatestTransactions } from '@/utils/tx-list' +import { getTransactionType } from '@/hooks/useTransactionType' +import usePreviousNonces from '@/hooks/usePreviousNonces' +import { isRejectionTx } from '@/utils/transactions' + +import css from './styles.module.css' +import classNames from 'classnames' + +const CustomPopper = function (props: PopperProps) { + return +} + +const NonceFormOption = memo(function NonceFormOption({ + nonce, + menuItemProps, +}: { + nonce: string + menuItemProps: MenuItemProps +}): ReactElement { + const addressBook = useAddressBook() + const transactions = useQueuedTxByNonce(Number(nonce)) + + const label = useMemo(() => { + const [{ transaction }] = getLatestTransactions(transactions) + return getTransactionType(transaction, addressBook).text + }, [addressBook, transactions]) + + return ( + + {nonce} ({label} transaction) + + ) +}) + +const getFieldMinWidth = (value: string): string => { + const MIN_CHARS = 5 + const MAX_WIDTH = '200px' + + return `clamp(calc(${MIN_CHARS}ch + 6px), calc(${Math.max(MIN_CHARS, value.length)}ch + 6px), ${MAX_WIDTH})` +} + +enum TxNonceFormFieldNames { + NONCE = 'nonce', +} + +const TxNonceForm = ({ nonce, recommendedNonce }: { nonce: string; recommendedNonce: string }) => { + const { safeTx, setNonce } = useContext(SafeTxContext) + const previousNonces = usePreviousNonces().map((nonce) => nonce.toString()) + const { safe } = useSafeInfo() + + const isEditable = !safeTx || safeTx?.signatures.size === 0 + const readOnly = !isEditable || isRejectionTx(safeTx) + + const formMethods = useForm({ + defaultValues: { + [TxNonceFormFieldNames.NONCE]: nonce, + }, + mode: 'all', + }) + + const resetNonce = () => { + formMethods.setValue(TxNonceFormFieldNames.NONCE, recommendedNonce) + } + + return ( + { + const newNonce = Number(value) + + if (isNaN(newNonce)) { + return 'Nonce must be a number' + } + + if (newNonce < safe.nonce) { + return `Nonce can't be lower than ${safe.nonce}` + } + + if (newNonce >= Number.MAX_SAFE_INTEGER) { + return 'Nonce is too high' + } + + // Update context with valid nonce + setNonce(newNonce) + }, + }} + render={({ field, fieldState }) => { + if (readOnly) { + return ( + + {nonce} + + ) + } + + const showRecommendedNonceButton = recommendedNonce !== field.value + + return ( + field.onChange(value)} + onInputChange={(_, value) => field.onChange(value)} + onBlur={() => { + field.onBlur() + + if (fieldState.error) { + formMethods.setValue(field.name, recommendedNonce.toString()) + } + }} + options={[recommendedNonce, ...previousNonces]} + getOptionLabel={(option) => option.toString()} + renderOption={(props, option) => { + return option === recommendedNonce ? ( + + {option} (recommended nonce) + + ) : ( + + ) + }} + disableClearable + componentsProps={{ + paper: { + elevation: 2, + }, + }} + renderInput={(params) => { + return ( + + + + + + + + + ) : null, + }} + className={classNames([ + css.input, + { + [css.withAdornment]: showRecommendedNonceButton, + }, + ])} + sx={{ + minWidth: getFieldMinWidth(field.value), + }} + /> + + ) + }} + PopperComponent={CustomPopper} + /> + ) + }} + /> + ) +} + +const skeletonMinWidth = getFieldMinWidth('') + +const TxNonce = () => { + const { nonce, recommendedNonce } = useContext(SafeTxContext) + + return ( + + Nonce{' '} + + # + + {nonce === undefined || recommendedNonce === undefined ? ( + + ) : ( + + )} + + ) +} + +export default TxNonce diff --git a/src/components/tx-flow/common/TxNonce/styles.module.css b/src/components/tx-flow/common/TxNonce/styles.module.css new file mode 100644 index 0000000000..614c607033 --- /dev/null +++ b/src/components/tx-flow/common/TxNonce/styles.module.css @@ -0,0 +1,19 @@ +.input :global .MuiOutlinedInput-root { + padding: 0; +} + +.input input { + font-weight: bold; + min-width: 0px !important; +} + +.input.withAdornment input { + padding-right: 24px !important; +} + +.adornment { + margin-left: 0; + margin-right: 4px; + position: absolute; + right: 0; +} diff --git a/src/components/tx-flow/common/TxStatusWidget/index.tsx b/src/components/tx-flow/common/TxStatusWidget/index.tsx new file mode 100644 index 0000000000..30862c0516 --- /dev/null +++ b/src/components/tx-flow/common/TxStatusWidget/index.tsx @@ -0,0 +1,115 @@ +import { Divider, IconButton, List, ListItem, ListItemIcon, ListItemText, Paper, Typography } from '@mui/material' +import CreatedIcon from '@/public/images/messages/created.svg' +import SignedIcon from '@/public/images/messages/signed.svg' +import { type TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' +import useSafeInfo from '@/hooks/useSafeInfo' +import { isMultisigExecutionInfo, isSignableBy } from '@/utils/transaction-guards' +import classnames from 'classnames' +import css from './styles.module.css' +import CloseIcon from '@mui/icons-material/Close' +import useWallet from '@/hooks/wallets/useWallet' +import SafeLogo from '@/public/images/logo-no-text.svg' +import { useContext } from 'react' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' + +const confirmedMessage = (threshold: number, confirmations: number) => { + return ( + <> + Confirmed ({confirmations} of {threshold}) + + ) +} + +const TxStatusWidget = ({ + step, + txSummary, + handleClose, + isReplacement = false, + isBatch = false, +}: { + step: number + txSummary?: TransactionSummary + handleClose: () => void + isReplacement?: boolean + isBatch?: boolean +}) => { + const wallet = useWallet() + const { safe } = useSafeInfo() + const { nonceNeeded } = useContext(SafeTxContext) + const { threshold } = safe + + const { executionInfo = undefined } = txSummary || {} + const { confirmationsSubmitted = 0 } = isMultisigExecutionInfo(executionInfo) ? executionInfo : {} + + const isConfirmedStepIncomplete = step < 1 && !confirmationsSubmitted + const canSign = txSummary ? isSignableBy(txSummary, wallet?.address || '') : true + + return ( + +
+ + + Transaction status + + + + +
+ + + +
+ + + + + + + {isReplacement ? 'Create replacement transaction' : isBatch ? 'Queue transactions' : 'Create'} + + + + + + + + + {isBatch ? ( + 'Create batch' + ) : !nonceNeeded ? ( + 'Confirmed' + ) : ( + <> + {confirmedMessage(threshold, confirmationsSubmitted)} + {canSign && ( + + +1 + + )} + + )} + + + + + + + + Execute + + + {isReplacement && ( + + + + + Transaction is replaced + + )} + +
+
+ ) +} + +export default TxStatusWidget diff --git a/src/components/tx-flow/common/TxStatusWidget/styles.module.css b/src/components/tx-flow/common/TxStatusWidget/styles.module.css new file mode 100644 index 0000000000..80817f9cd0 --- /dev/null +++ b/src/components/tx-flow/common/TxStatusWidget/styles.module.css @@ -0,0 +1,93 @@ +.header { + padding: var(--space-3); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); +} + +.content { + padding: var(--space-3); +} + +.status { + padding: 0; +} + +.status::before { + content: ''; + position: absolute; + border-left: 2px solid var(--color-border-light); + left: 15px; + top: 20px; + height: calc(100% - 40px); +} + +.status :global .MuiListItem-root:first-of-type { + padding-top: 0; +} + +.status :global .MuiListItem-root { + padding-left: 0; + padding-right: 0; +} + +.status :global .MuiListItemIcon-root { + color: var(--color-primary-main); + justify-content: center; + min-width: 32px; + padding: var(--space-1); + background-color: var(--color-background-paper); +} + +.incomplete > * { + color: var(--color-text-secondary) !important; +} + +.close { + color: var(--color-border-main); + padding: var(--space-2); + border-left: 1px solid var(--color-border-light); + border-radius: 0; + margin-left: auto; + display: none; +} + +.badge { + margin-left: var(--space-1); + padding-right: 2px; + border-radius: 50%; + width: var(--space-3); + height: var(--space-3); + display: inline-flex; + align-items: center; + justify-content: center; + background-color: var(--color-secondary-main); + color: var(--color-text-primary); +} + +[data-theme='dark'] .badge { + background-color: var(--color-primary-main); + color: var(--color-text-secondary); +} + +@media (max-width: 899.95px) { + .header { + padding: 0; + flex-direction: row; + } + + .logo { + width: 24px; + height: 24px; + margin-left: 16px; + } + + .title { + font-size: 16px; + } + + .close { + display: flex; + } +} diff --git a/src/components/tx-flow/common/constants.ts b/src/components/tx-flow/common/constants.ts new file mode 100644 index 0000000000..60e9de90a6 --- /dev/null +++ b/src/components/tx-flow/common/constants.ts @@ -0,0 +1,4 @@ +export const TOOLTIP_TITLES = { + THRESHOLD: + 'The threshold of a Safe Account specifies how many owners need to confirm a Safe Account transaction before it can be executed.', +} as const diff --git a/src/components/tx-flow/common/styles.module.css b/src/components/tx-flow/common/styles.module.css new file mode 100644 index 0000000000..55fb434f16 --- /dev/null +++ b/src/components/tx-flow/common/styles.module.css @@ -0,0 +1,18 @@ +.cardContent { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3); +} + +.cardContent :global .errorMessage { + margin: 0; +} + +.nestedDivider { + margin: 0 calc(-1 * var(--space-3)); +} + +.form > :global(.MuiFormControl-root) { + margin-bottom: 28px; +} diff --git a/src/components/tx-flow/flows/AddOwner/ChooseOwner.tsx b/src/components/tx-flow/flows/AddOwner/ChooseOwner.tsx new file mode 100644 index 0000000000..73590da568 --- /dev/null +++ b/src/components/tx-flow/flows/AddOwner/ChooseOwner.tsx @@ -0,0 +1,189 @@ +import { EthHashInfo } from '@safe-global/safe-react-components' +import { + Box, + Typography, + FormControl, + InputAdornment, + CircularProgress, + Button, + CardActions, + Divider, + Grid, + TextField, + MenuItem, + SvgIcon, + Tooltip, +} from '@mui/material' +import { useForm, FormProvider, Controller } from 'react-hook-form' + +import AddressBookInput from '@/components/common/AddressBookInput' +import NameInput from '@/components/common/NameInput' +import { useAddressResolver } from '@/hooks/useAddressResolver' +import useSafeInfo from '@/hooks/useSafeInfo' +import { uniqueAddress, addressIsNotCurrentSafe } from '@/utils/validation' +import type { AddOwnerFlowProps } from '.' +import type { ReplaceOwnerFlowProps } from '../ReplaceOwner' +import TxCard from '../../common/TxCard' +import InfoIcon from '@/public/images/notifications/info.svg' +import commonCss from '@/components/tx-flow/common/styles.module.css' +import { TOOLTIP_TITLES } from '@/components/tx-flow/common/constants' + +type FormData = Pick + +export enum ChooseOwnerMode { + REPLACE, + ADD, +} + +export const ChooseOwner = ({ + params, + onSubmit, + mode, +}: { + params: AddOwnerFlowProps | ReplaceOwnerFlowProps + onSubmit: (data: FormData) => void + mode: ChooseOwnerMode +}) => { + const { safe, safeAddress } = useSafeInfo() + + const formMethods = useForm({ + defaultValues: params, + mode: 'onChange', + }) + const { handleSubmit, formState, watch, control } = formMethods + const isValid = Object.keys(formState.errors).length === 0 // do not use formState.isValid because names can be empty + + const notAlreadyOwner = uniqueAddress(safe.owners.map((owner) => owner.value)) + const notCurrentSafe = addressIsNotCurrentSafe(safeAddress) + const combinedValidate = (address: string) => notAlreadyOwner(address) || notCurrentSafe(address) + + const address = watch('newOwner.address') + + const { name, ens, resolving } = useAddressResolver(address) + + // Address book, ENS + const fallbackName = name || ens + + const onFormSubmit = handleSubmit((formData: FormData) => { + onSubmit({ + ...formData, + newOwner: { + ...formData.newOwner, + name: formData.newOwner.name || fallbackName, + }, + threshold: formData.threshold, + }) + }) + + const newNumberOfOwners = safe.owners.length + (!params.removedOwner ? 1 : 0) + + return ( + + + + {params.removedOwner && ( + <> + + {params.removedOwner && + 'Review the owner you want to replace in the active Safe Account, then specify the new owner you want to replace it with:'} + + + + Current owner + + + + + )} + + + + + + ), + }} + /> + + + + + + + + + {mode === ChooseOwnerMode.ADD && ( + + + Threshold + + + + + + + + + Any transaction requires the confirmation of: + + + + + ( + + {safe.owners.map((_, idx) => ( + + {idx + 1} + + ))} + {!params.removedOwner && ( + + {newNumberOfOwners} + + )} + + )} + /> + + + out of {newNumberOfOwners} owner(s) + + + + )} + + + + + + + + + + ) +} diff --git a/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx new file mode 100644 index 0000000000..d2c18301b3 --- /dev/null +++ b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx @@ -0,0 +1,88 @@ +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' +import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' +import { createSwapOwnerTx, createAddOwnerTx } from '@/services/tx/tx-sender' +import { useAppDispatch } from '@/store' +import { upsertAddressBookEntry } from '@/store/addressBookSlice' +import { SafeTxContext } from '../../SafeTxProvider' +import type { AddOwnerFlowProps } from '.' +import type { ReplaceOwnerFlowProps } from '../ReplaceOwner' +import PlusIcon from '@/public/images/common/plus.svg' +import MinusIcon from '@/public/images/common/minus.svg' +import EthHashInfo from '@/components/common/EthHashInfo' +import commonCss from '@/components/tx-flow/common/styles.module.css' + +export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwnerFlowProps }) => { + const dispatch = useAppDispatch() + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) + const { safe } = useSafeInfo() + const { chainId } = safe + const { newOwner, removedOwner, threshold } = params + + useEffect(() => { + const promise = removedOwner + ? createSwapOwnerTx({ + newOwnerAddress: newOwner.address, + oldOwnerAddress: removedOwner.address, + }) + : createAddOwnerTx({ + ownerAddress: newOwner.address, + threshold, + }) + + promise.then(setSafeTx).catch(setSafeTxError) + }, [removedOwner, newOwner, threshold, setSafeTx, setSafeTxError]) + + const addAddressBookEntryAndSubmit = () => { + if (typeof newOwner.name !== 'undefined') { + dispatch( + upsertAddressBookEntry({ + chainId: chainId, + address: newOwner.address, + name: newOwner.name, + }), + ) + } + + trackEvent({ ...SETTINGS_EVENTS.SETUP.THRESHOLD, label: safe.threshold }) + trackEvent({ ...SETTINGS_EVENTS.SETUP.OWNERS, label: safe.owners.length }) + } + + return ( + + {params.removedOwner && ( + palette.warning.background, p: 2 }}> + + + Previous owner + + + + )} + palette.success.background, p: 2 }}> + + + New owner + + + + + + Any transaction requires the confirmation of: + + {threshold} out of {safe.owners.length + (removedOwner ? 0 : 1)} owners + + + + + ) +} diff --git a/src/components/tx-flow/flows/AddOwner/index.tsx b/src/components/tx-flow/flows/AddOwner/index.tsx new file mode 100644 index 0000000000..43d20cfec0 --- /dev/null +++ b/src/components/tx-flow/flows/AddOwner/index.tsx @@ -0,0 +1,55 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import useTxStepper from '@/components/tx-flow/useTxStepper' +import { ChooseOwner, ChooseOwnerMode } from '@/components/tx-flow/flows/AddOwner/ChooseOwner' +import { ReviewOwner } from '@/components/tx-flow/flows/AddOwner/ReviewOwner' +import SaveAddressIcon from '@/public/images/common/save-address.svg' +import useSafeInfo from '@/hooks/useSafeInfo' + +type Owner = { + address: string + name?: string +} + +export type AddOwnerFlowProps = { + newOwner: Owner + removedOwner?: Owner + threshold: number +} + +const AddOwnerFlow = () => { + const { safe } = useSafeInfo() + + const defaultValues: AddOwnerFlowProps = { + newOwner: { + address: '', + name: '', + }, + threshold: safe.threshold, + } + + const { data, step, nextStep, prevStep } = useTxStepper(defaultValues) + + const steps = [ + nextStep({ ...data, ...formData })} + mode={ChooseOwnerMode.ADD} + />, + , + ] + + return ( + + {steps} + + ) +} + +export default AddOwnerFlow diff --git a/src/components/tx-flow/flows/ChangeThreshold/ChooseThreshold.tsx b/src/components/tx-flow/flows/ChangeThreshold/ChooseThreshold.tsx new file mode 100644 index 0000000000..5b113d3c8e --- /dev/null +++ b/src/components/tx-flow/flows/ChangeThreshold/ChooseThreshold.tsx @@ -0,0 +1,135 @@ +import { Controller, useForm } from 'react-hook-form' +import { + TextField, + MenuItem, + Button, + CardActions, + Divider, + Typography, + Box, + Grid, + SvgIcon, + Tooltip, +} from '@mui/material' +import type { ReactElement } from 'react' + +import useSafeInfo from '@/hooks/useSafeInfo' +import TxCard from '@/components/tx-flow/common/TxCard' +import { ChangeThresholdFlowFieldNames } from '@/components/tx-flow/flows/ChangeThreshold' +import type { ChangeThresholdFlowProps } from '@/components/tx-flow/flows/ChangeThreshold' +import InfoIcon from '@/public/images/notifications/info.svg' +import { TOOLTIP_TITLES } from '@/components/tx-flow/common/constants' + +import commonCss from '@/components/tx-flow/common/styles.module.css' + +export const ChooseThreshold = ({ + params, + onSubmit, +}: { + params: ChangeThresholdFlowProps + onSubmit: (data: ChangeThresholdFlowProps) => void +}): ReactElement => { + const { safe } = useSafeInfo() + + const formMethods = useForm({ + defaultValues: params, + mode: 'onChange', + }) + + const newThreshold = formMethods.watch(ChangeThresholdFlowFieldNames.threshold) + + return ( + +
+ + Threshold + + + + + + + + Any transaction will require the confirmation of: +
+ +
+ + { + if (value === safe.threshold) { + return `Current policy is already set to ${safe.threshold}.` + } + }, + }} + name={ChangeThresholdFlowFieldNames.threshold} + render={({ field, fieldState }) => { + const isError = !!fieldState.error + + return ( + + + + {safe.owners.map((_, idx) => ( + + {idx + 1} + + ))} + + + + + out of {safe.owners.length} owner(s) + + + + {isError ? ( + + {fieldState.error?.message} + + ) : ( + + {fieldState.isDirty ? 'Previous policy was ' : 'Current policy is '} + + {safe.threshold} out of {safe.owners.length} + + . + + )} + + + ) + }} + /> + + + + + + + + +
+ ) +} diff --git a/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx b/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx new file mode 100644 index 0000000000..6c1aa11d61 --- /dev/null +++ b/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx @@ -0,0 +1,47 @@ +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' +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +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' + +const ReviewChangeThreshold = ({ params }: { params: ChangeThresholdFlowProps }) => { + const { safe } = useSafeInfo() + const newThreshold = params[ChangeThresholdFlowFieldNames.threshold] + + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) + + useEffect(() => { + createUpdateThresholdTx(newThreshold).then(setSafeTx).catch(setSafeTxError) + }, [newThreshold, setSafeTx, setSafeTxError]) + + const onChangeThreshold = () => { + trackEvent({ ...SETTINGS_EVENTS.SETUP.OWNERS, label: safe.owners.length }) + trackEvent({ ...SETTINGS_EVENTS.SETUP.THRESHOLD, label: newThreshold }) + } + + return ( + +
+ + Any transaction will require the confirmation of: + + + + {newThreshold} out of {safe.owners.length} owner(s) + +
+ + + +
+ ) +} + +export default ReviewChangeThreshold diff --git a/src/components/tx-flow/flows/ChangeThreshold/index.tsx b/src/components/tx-flow/flows/ChangeThreshold/index.tsx new file mode 100644 index 0000000000..907d48da78 --- /dev/null +++ b/src/components/tx-flow/flows/ChangeThreshold/index.tsx @@ -0,0 +1,41 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import ReviewChangeThreshold from '@/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold' +import useTxStepper from '@/components/tx-flow/useTxStepper' +import SaveAddressIcon from '@/public/images/common/save-address.svg' +import useSafeInfo from '@/hooks/useSafeInfo' +import { ChooseThreshold } from '@/components/tx-flow/flows/ChangeThreshold/ChooseThreshold' + +export enum ChangeThresholdFlowFieldNames { + threshold = 'threshold', +} + +export type ChangeThresholdFlowProps = { + [ChangeThresholdFlowFieldNames.threshold]: number +} + +const ChangeThresholdFlow = () => { + const { safe } = useSafeInfo() + + const { data, step, nextStep, prevStep } = useTxStepper({ + [ChangeThresholdFlowFieldNames.threshold]: safe.threshold, + }) + + const steps = [ + nextStep(formData)} />, + , + ] + + return ( + + {steps} + + ) +} + +export default ChangeThresholdFlow diff --git a/src/components/tx/modals/ConfirmTxModal/ConfirmProposedTx.tsx b/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx similarity index 55% rename from src/components/tx/modals/ConfirmTxModal/ConfirmProposedTx.tsx rename to src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx index 27e499eb84..557378f935 100644 --- a/src/components/tx/modals/ConfirmTxModal/ConfirmProposedTx.tsx +++ b/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx @@ -1,59 +1,41 @@ -import type { ReactElement } from 'react' +import { type ReactElement, useContext, useEffect } from 'react' import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' - import useSafeInfo from '@/hooks/useSafeInfo' import { useChainId } from '@/hooks/useChainId' -import useAsync from '@/hooks/useAsync' import useWallet from '@/hooks/wallets/useWallet' import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { isExecutable, isSignableBy } from '@/utils/transaction-guards' -import { Skeleton, Typography } from '@mui/material' +import { Typography } from '@mui/material' import { createExistingTx } from '@/services/tx/tx-sender' +import { SafeTxContext } from '../../SafeTxProvider' type ConfirmProposedTxProps = { txSummary: TransactionSummary - onSubmit: () => void } const SIGN_TEXT = 'Sign this transaction.' const EXECUTE_TEXT = 'Submit the form to execute this transaction.' const SIGN_EXECUTE_TEXT = 'Sign or immediately execute this transaction.' -const ConfirmProposedTx = ({ txSummary, onSubmit }: ConfirmProposedTxProps): ReactElement => { +const ConfirmProposedTx = ({ txSummary }: ConfirmProposedTxProps): ReactElement => { const wallet = useWallet() const { safe, safeAddress } = useSafeInfo() const chainId = useChainId() + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) const txId = txSummary.id const canExecute = isExecutable(txSummary, wallet?.address || '', safe) const canSign = isSignableBy(txSummary, wallet?.address || '') - const [safeTx, safeTxError] = useAsync(() => { - return createExistingTx(chainId, safeAddress, txId) - }, [txId, safeAddress, chainId]) + useEffect(() => { + createExistingTx(chainId, safeAddress, txId).then(setSafeTx).catch(setSafeTxError) + }, [txId, safeAddress, chainId, setSafeTx, setSafeTxError]) const text = canSign ? (canExecute ? SIGN_EXECUTE_TEXT : SIGN_TEXT) : EXECUTE_TEXT return ( - + {}} isExecutable={canExecute} onlyExecute={!canSign}> {text} - - - Transaction nonce:  - {safeTx ? ( - {safeTx?.data.nonce} - ) : ( - - )} - ) } diff --git a/src/components/tx-flow/flows/ConfirmTx/index.tsx b/src/components/tx-flow/flows/ConfirmTx/index.tsx new file mode 100644 index 0000000000..5df1140521 --- /dev/null +++ b/src/components/tx-flow/flows/ConfirmTx/index.tsx @@ -0,0 +1,27 @@ +import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' +import TxLayout from '@/components/tx-flow/common/TxLayout' +import ConfirmProposedTx from './ConfirmProposedTx' +import { useTransactionType } from '@/hooks/useTransactionType' +import TxInfo from '@/components/transactions/TxInfo' + +const ConfirmTxFlow = ({ txSummary }: { txSummary: TransactionSummary }) => { + const { text } = useTransactionType(txSummary) + + return ( + + {text}  + + + } + step={0} + txSummary={txSummary} + > + + + ) +} + +export default ConfirmTxFlow diff --git a/src/components/tx-flow/flows/ExecuteBatch/DecodedTxs.tsx b/src/components/tx-flow/flows/ExecuteBatch/DecodedTxs.tsx new file mode 100644 index 0000000000..1c28414bcf --- /dev/null +++ b/src/components/tx-flow/flows/ExecuteBatch/DecodedTxs.tsx @@ -0,0 +1,73 @@ +import type { DataDecoded, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { Box } from '@mui/material' +import useSafeInfo from '@/hooks/useSafeInfo' +import extractTxInfo from '@/services/tx/extractTxInfo' +import { isCustomTxInfo, isNativeTokenTransfer, isTransferTxInfo } from '@/utils/transaction-guards' +import SingleTxDecoded from '@/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded' +import css from '@/components/transactions/TxDetails/TxData/DecodedData/Multisend/styles.module.css' +import { useState } from 'react' +import { MultisendActionsHeader } from '@/components/transactions/TxDetails/TxData/DecodedData/Multisend' +import { type AccordionProps } from '@mui/material/Accordion/Accordion' + +const DecodedTxs = ({ txs }: { txs: TransactionDetails[] | undefined }) => { + const [openMap, setOpenMap] = useState>() + const { safeAddress } = useSafeInfo() + + if (!txs) return null + + return ( + <> + + + + {txs.map((transaction, idx) => { + if (!transaction.txData) return null + + const onChange: AccordionProps['onChange'] = (_, expanded) => { + setOpenMap((prev) => ({ + ...prev, + [idx]: expanded, + })) + } + + const { txParams } = extractTxInfo(transaction, safeAddress) + + let decodedDataParams: DataDecoded = { + method: '', + parameters: undefined, + } + + if (isCustomTxInfo(transaction.txInfo) && transaction.txInfo.isCancellation) { + decodedDataParams.method = 'On-chain rejection' + } + + if (isTransferTxInfo(transaction.txInfo) && isNativeTokenTransfer(transaction.txInfo.transferInfo)) { + decodedDataParams.method = 'transfer' + } + + const dataDecoded = transaction.txData.dataDecoded || decodedDataParams + + return ( + + ) + })} + + + ) +} + +export default DecodedTxs diff --git a/src/components/tx/modals/BatchExecuteModal/ReviewBatchExecute.tsx b/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx similarity index 66% rename from src/components/tx/modals/BatchExecuteModal/ReviewBatchExecute.tsx rename to src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx index d21069683e..b69b89cdd7 100644 --- a/src/components/tx/modals/BatchExecuteModal/ReviewBatchExecute.tsx +++ b/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx @@ -1,3 +1,4 @@ +import { Typography, Button, CardActions, Divider, Alert } from '@mui/material' import useAsync from '@/hooks/useAsync' import { FEATURES } from '@safe-global/safe-gateway-typescript-sdk' import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' @@ -5,34 +6,41 @@ import { getMultiSendCallOnlyContract } from '@/services/contracts/safeContracts import { useCurrentChain } from '@/hooks/useChains' import useSafeInfo from '@/hooks/useSafeInfo' import { encodeMultiSendData } from '@safe-global/safe-core-sdk/dist/src/utils/transactions/utils' -import { Button, DialogContent, Typography } from '@mui/material' -import SendToBlock from '@/components/tx/SendToBlock' -import { type SyntheticEvent, useMemo, useState } from 'react' +import { useState, useMemo, useContext } from 'react' +import type { SyntheticEvent } from 'react' import { generateDataRowValue } from '@/components/transactions/TxDetails/Summary/TxDataRow' -import { Errors, logError } from '@/services/exceptions' import ErrorMessage from '@/components/tx/ErrorMessage' -import type { BatchExecuteData } from '@/components/tx/modals/BatchExecuteModal/index' -import DecodedTxs from '@/components/tx/modals/BatchExecuteModal/DecodedTxs' -import { getMultiSendTxs, getTxsWithDetails } from '@/utils/transactions' -import { TxSimulation } from '@/components/tx/TxSimulation' +import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector' +import DecodedTxs from '@/components/tx-flow/flows/ExecuteBatch/DecodedTxs' +import { TxSimulation } from '@/components/tx/security/tenderly' +import { WrongChainWarning } from '@/components/tx/WrongChainWarning' import { useRelaysBySafe } from '@/hooks/useRemainingRelays' -import { ExecutionMethod, ExecutionMethodSelector } from '../../ExecutionMethodSelector' -import { dispatchBatchExecution, dispatchBatchExecutionRelay } from '@/services/tx/tx-sender' import useOnboard from '@/hooks/wallets/useOnboard' -import { WrongChainWarning } from '@/components/tx/WrongChainWarning' import { isWeb3ReadOnly, useWeb3 } from '@/hooks/wallets/web3' +import { logError, Errors } from '@/services/exceptions' +import { dispatchBatchExecution, dispatchBatchExecutionRelay } from '@/services/tx/tx-sender' import { hasRemainingRelays } from '@/utils/relaying' +import { getTxsWithDetails, getMultiSendTxs } from '@/utils/transactions' +import TxCard from '../../common/TxCard' +import CheckWallet from '@/components/common/CheckWallet' +import type { ExecuteBatchFlowProps } from '.' +import { asError } from '@/services/exceptions/utils' +import SendToBlock from '@/components/tx-flow/flows/TokenTransfer/SendToBlock' +import ConfirmationTitle, { ConfirmationTitleTypes } from '@/components/tx/SignOrExecuteForm/ConfirmationTitle' +import commonCss from '@/components/tx-flow/common/styles.module.css' +import { TxModalContext } from '@/components/tx-flow' import useGasPrice from '@/hooks/useGasPrice' import { hasFeature } from '@/utils/chains' import type { PayableOverrides } from 'ethers' -const ReviewBatchExecute = ({ data, onSubmit }: { data: BatchExecuteData; onSubmit: (data: null) => void }) => { +export const ReviewBatch = ({ params }: { params: ExecuteBatchFlowProps }) => { const [isSubmittable, setIsSubmittable] = useState(true) const [submitError, setSubmitError] = useState() const [executionMethod, setExecutionMethod] = useState(ExecutionMethod.RELAY) const chain = useCurrentChain() const { safe } = useSafeInfo() const [relays] = useRelaysBySafe() + const { setTxFlow } = useContext(TxModalContext) const [gasPrice, , gasPriceLoading] = useGasPrice() const maxFeePerGas = gasPrice?.maxFeePerGas @@ -48,9 +56,8 @@ const ReviewBatchExecute = ({ data, onSubmit }: { data: BatchExecuteData; onSubm const [txsWithDetails, error, loading] = useAsync(() => { if (!chain?.chainId) return - - return getTxsWithDetails(data.txs, chain.chainId) - }, [data.txs, chain?.chainId]) + return getTxsWithDetails(params.txs, chain.chainId) + }, [params.txs, chain?.chainId]) const multiSendContract = useMemo(() => { if (!chain?.chainId || !safe.version || !web3 || isWeb3ReadOnly(web3)) return @@ -83,7 +90,6 @@ const ReviewBatchExecute = ({ data, onSubmit }: { data: BatchExecuteData; onSubm safe.address.value, overrides, ) - onSubmit(null) } const onRelay = async () => { @@ -96,8 +102,6 @@ const ReviewBatchExecute = ({ data, onSubmit }: { data: BatchExecuteData; onSubm safe.chainId, safe.address.value, ) - - onSubmit(null) } const handleSubmit = async (e: SyntheticEvent) => { @@ -107,10 +111,12 @@ const ReviewBatchExecute = ({ data, onSubmit }: { data: BatchExecuteData; onSubm try { await (willRelay ? onRelay() : onExecute()) - } catch (err) { - logError(Errors._804, (err as Error).message) + setTxFlow(undefined) + } catch (_err) { + const err = asError(_err) + logError(Errors._804, err) setIsSubmittable(true) - setSubmitError(err as Error) + setSubmitError(err) return } } @@ -118,10 +124,10 @@ const ReviewBatchExecute = ({ data, onSubmit }: { data: BatchExecuteData; onSubm const submitDisabled = loading || !isSubmittable || gasPriceLoading return ( -
- - - This transaction batches a total of {data.txs.length} transactions from your queue into a single Ethereum + <> + + + This transaction batches a total of {params.txs.length} transactions from your queue into a single Ethereum transaction. Please check every included transaction carefully, especially if you have rejection transactions, and make sure you want to execute all of them. Included transactions are highlighted in green when you hover over the execute button. @@ -130,42 +136,47 @@ const ReviewBatchExecute = ({ data, onSubmit }: { data: BatchExecuteData; onSubm {multiSendContract && } {multiSendTxData && ( - <> - +
+ Data (hex encoded) {generateDataRowValue(multiSendTxData, 'rawData')} - +
)} - - Batched transactions: - - +
+ +
+
+ + {multiSendTxs && ( + + Transaction checks + + + + )} + + + + + {canRelay ? ( <> - - Gas fees: - ) : null} - {multiSendTxs && } - - - - + Be aware that if any of the included transactions revert, none of them will be executed. This will result in the loss of the allocated transaction fees. - + {error && ( @@ -177,17 +188,20 @@ executions from the same Safe Account." Error submitting the transaction. Please try again. )} - -
-
+
+ + + + + {(isOk) => ( + + )} + + +
+ + ) } - -export default ReviewBatchExecute diff --git a/src/components/tx-flow/flows/ExecuteBatch/index.tsx b/src/components/tx-flow/flows/ExecuteBatch/index.tsx new file mode 100644 index 0000000000..a2b9143adb --- /dev/null +++ b/src/components/tx-flow/flows/ExecuteBatch/index.tsx @@ -0,0 +1,19 @@ +import type { Transaction } from '@safe-global/safe-gateway-typescript-sdk' + +import TxLayout from '@/components/tx-flow/common/TxLayout' +import { ReviewBatch } from './ReviewBatch' +import BatchIcon from '@/public/images/apps/batch-icon.svg' + +export type ExecuteBatchFlowProps = { + txs: Transaction[] +} + +const ExecuteBatchFlow = (props: ExecuteBatchFlowProps) => { + return ( + + + + ) +} + +export default ExecuteBatchFlow diff --git a/src/components/tx-flow/flows/NewSpendingLimit/CreateSpendingLimit.tsx b/src/components/tx-flow/flows/NewSpendingLimit/CreateSpendingLimit.tsx new file mode 100644 index 0000000000..fd0c23a4e0 --- /dev/null +++ b/src/components/tx-flow/flows/NewSpendingLimit/CreateSpendingLimit.tsx @@ -0,0 +1,122 @@ +import { useCallback, useMemo, useState } from 'react' +import { Controller, FormProvider, useForm } from 'react-hook-form' +import { Box, Button, CardActions, FormControl, InputLabel, MenuItem, Select, Typography } from '@mui/material' +import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded' +import { defaultAbiCoder, parseUnits } from 'ethers/lib/utils' + +import AddressBookInput from '@/components/common/AddressBookInput' +import useChainId from '@/hooks/useChainId' +import { getResetTimeOptions } from '@/components/transactions/TxDetails/TxData/SpendingLimits' +import { useVisibleBalances } from '@/hooks/useVisibleBalances' +import type { NewSpendingLimitFlowProps } from '.' +import TxCard from '../../common/TxCard' +import css from '@/components/tx/ExecuteCheckbox/styles.module.css' +import TokenAmountInput from '@/components/common/TokenAmountInput' +import { SpendingLimitFields } from '.' +import { validateAmount, validateDecimalLength } from '@/utils/validation' +import AddressInputReadOnly from '@/components/common/AddressInputReadOnly' +import useAddressBook from '@/hooks/useAddressBook' + +export const _validateSpendingLimit = (val: string, decimals?: number) => { + // Allowance amount is uint96 https://github.com/safe-global/safe-modules/blob/master/allowances/contracts/AlowanceModule.sol#L52 + try { + const amount = parseUnits(val, decimals) + defaultAbiCoder.encode(['int96'], [amount]) + } catch (e) { + return Number(val) > 1 ? 'Amount is too big' : 'Amount is too small' + } +} + +export const CreateSpendingLimit = ({ + params, + onSubmit, +}: { + params: NewSpendingLimitFlowProps + onSubmit: (data: NewSpendingLimitFlowProps) => void +}) => { + const [recipientFocus, setRecipientFocus] = useState(!params.beneficiary) + const chainId = useChainId() + const { balances } = useVisibleBalances() + const addressBook = useAddressBook() + + const resetTimeOptions = useMemo(() => getResetTimeOptions(chainId), [chainId]) + + const formMethods = useForm({ + defaultValues: params, + mode: 'onChange', + }) + + const { handleSubmit, setValue, watch, control } = formMethods + + const beneficiary = watch(SpendingLimitFields.beneficiary) + const tokenAddress = watch(SpendingLimitFields.tokenAddress) + const selectedToken = tokenAddress + ? balances.items.find((item) => item.tokenInfo.address === tokenAddress) + : undefined + + const validateSpendingLimit = useCallback( + (value: string) => { + return ( + validateAmount(value) || + validateDecimalLength(value, selectedToken?.tokenInfo.decimals) || + _validateSpendingLimit(value, selectedToken?.tokenInfo.decimals) + ) + }, + [selectedToken?.tokenInfo.decimals], + ) + + return ( + + +
+ + {addressBook[beneficiary] ? ( + { + setValue(SpendingLimitFields.beneficiary, '') + setRecipientFocus(true) + }} + > + + + ) : ( + + )} + + + + + + Reset Timer + + + Set a reset time so the allowance automatically refills after the defined time period. + + + Time Period + ( + + )} + /> + + + + + + +
+
+ ) +} diff --git a/src/components/tx-flow/flows/NewSpendingLimit/ReviewSpendingLimit.tsx b/src/components/tx-flow/flows/NewSpendingLimit/ReviewSpendingLimit.tsx new file mode 100644 index 0000000000..5ab7d1a2a2 --- /dev/null +++ b/src/components/tx-flow/flows/NewSpendingLimit/ReviewSpendingLimit.tsx @@ -0,0 +1,144 @@ +import { useState, useEffect, useMemo, useContext } from 'react' +import { useSelector } from 'react-redux' +import { BigNumber } from 'ethers' +import { Typography, Grid, Alert } from '@mui/material' + +import SpendingLimitLabel from '@/components/common/SpendingLimitLabel' +import { getResetTimeOptions } from '@/components/transactions/TxDetails/TxData/SpendingLimits' +import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock' +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +import useBalances from '@/hooks/useBalances' +import useChainId from '@/hooks/useChainId' +import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' +import { createNewSpendingLimitTx } from '@/services/tx/tx-sender' +import { selectSpendingLimits } from '@/store/spendingLimitsSlice' +import { formatVisualAmount } from '@/utils/formatters' +import type { SpendingLimitState } from '@/store/spendingLimitsSlice' +import type { NewSpendingLimitFlowProps } from '.' +import EthHashInfo from '@/components/common/EthHashInfo' +import { SafeTxContext } from '../../SafeTxProvider' + +export const ReviewSpendingLimit = ({ params }: { params: NewSpendingLimitFlowProps }) => { + const [existingSpendingLimit, setExistingSpendingLimit] = useState() + const spendingLimits = useSelector(selectSpendingLimits) + const chainId = useChainId() + const { balances } = useBalances() + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) + const token = balances.items.find((item) => item.tokenInfo.address === params.tokenAddress) + const { decimals } = token?.tokenInfo || {} + + useEffect(() => { + const existingSpendingLimit = spendingLimits.find( + (spendingLimit) => + spendingLimit.beneficiary === params.beneficiary && spendingLimit.token.address === params.tokenAddress, + ) + setExistingSpendingLimit(existingSpendingLimit) + }, [spendingLimits, params]) + + useEffect(() => { + createNewSpendingLimitTx(params, spendingLimits, chainId, decimals, existingSpendingLimit) + .then(setSafeTx) + .catch(setSafeTxError) + }, [chainId, decimals, existingSpendingLimit, params, setSafeTx, setSafeTxError, spendingLimits]) + + const isOneTime = params.resetTime === '0' + const resetTime = useMemo(() => { + return isOneTime + ? 'One-time spending limit' + : getResetTimeOptions(chainId).find((time) => time.value === params.resetTime)?.label + }, [isOneTime, params.resetTime, chainId]) + + const onFormSubmit = () => { + trackEvent({ + ...SETTINGS_EVENTS.SPENDING_LIMIT.RESET_PERIOD, + label: resetTime, + }) + } + + const existingAmount = existingSpendingLimit + ? formatVisualAmount(BigNumber.from(existingSpendingLimit?.amount), decimals) + : undefined + + const oldResetTime = existingSpendingLimit + ? getResetTimeOptions(chainId).find((time) => time.value === existingSpendingLimit?.resetTimeMin)?.label + : undefined + + return ( + + {token && ( + + {existingAmount && existingAmount !== params.amount && ( + <> + + {existingAmount} + + {'→'} + + )} + + )} + + + + + Beneficiary + + + + + + + + + + + + Reset time + + + + {existingSpendingLimit ? ( + <> + + {existingSpendingLimit.resetTimeMin !== params.resetTime && ( + <> + + {oldResetTime} + + {' → '} + + )} + + {resetTime} + + + } + isOneTime={existingSpendingLimit.resetTimeMin === '0'} + /> + + ) : ( + + )} + + + {existingSpendingLimit && ( + + You are about to replace an existing spending limit + + )} + + ) +} diff --git a/src/components/settings/SpendingLimits/NewSpendingLimit/__tests__/SpendingLimitForm.test.ts b/src/components/tx-flow/flows/NewSpendingLimit/__tests__/SpendingLimitForm.test.ts similarity index 87% rename from src/components/settings/SpendingLimits/NewSpendingLimit/__tests__/SpendingLimitForm.test.ts rename to src/components/tx-flow/flows/NewSpendingLimit/__tests__/SpendingLimitForm.test.ts index 70891e3e1e..df35b74995 100644 --- a/src/components/settings/SpendingLimits/NewSpendingLimit/__tests__/SpendingLimitForm.test.ts +++ b/src/components/tx-flow/flows/NewSpendingLimit/__tests__/SpendingLimitForm.test.ts @@ -1,6 +1,6 @@ -import { _validateSpendingLimit } from '../steps/SpendingLimitForm' +import { _validateSpendingLimit } from '../CreateSpendingLimit' -describe('SpendingLimitForm', () => { +describe('CreateSpendingLimit', () => { describe('validateSpendingLimit', () => { it('should return no error if the amount is valid', () => { const result1 = _validateSpendingLimit('9999999999.999999999999999999') diff --git a/src/components/tx-flow/flows/NewSpendingLimit/index.tsx b/src/components/tx-flow/flows/NewSpendingLimit/index.tsx new file mode 100644 index 0000000000..aaea462271 --- /dev/null +++ b/src/components/tx-flow/flows/NewSpendingLimit/index.tsx @@ -0,0 +1,51 @@ +import TxLayout from '../../common/TxLayout' +import useTxStepper from '../../useTxStepper' +import { CreateSpendingLimit } from './CreateSpendingLimit' +import { ReviewSpendingLimit } from './ReviewSpendingLimit' +import SaveAddressIcon from '@/public/images/common/save-address.svg' +import { ZERO_ADDRESS } from '@safe-global/safe-core-sdk/dist/src/utils/constants' +import { TokenAmountFields } from '@/components/common/TokenAmountInput' + +enum Fields { + beneficiary = 'beneficiary', + resetTime = 'resetTime', +} + +export const SpendingLimitFields = { ...Fields, ...TokenAmountFields } + +export type NewSpendingLimitFlowProps = { + [SpendingLimitFields.beneficiary]: string + [SpendingLimitFields.tokenAddress]: string + [SpendingLimitFields.amount]: string + [SpendingLimitFields.resetTime]: string +} + +const defaultValues: NewSpendingLimitFlowProps = { + beneficiary: '', + tokenAddress: ZERO_ADDRESS, + amount: '', + resetTime: '0', +} + +const NewSpendingLimitFlow = () => { + const { data, step, nextStep, prevStep } = useTxStepper(defaultValues) + + const steps = [ + nextStep({ ...data, ...formData })} />, + , + ] + + return ( + + {steps} + + ) +} + +export default NewSpendingLimitFlow diff --git a/src/components/tx-flow/flows/NewTx/index.tsx b/src/components/tx-flow/flows/NewTx/index.tsx new file mode 100644 index 0000000000..55e85333ef --- /dev/null +++ b/src/components/tx-flow/flows/NewTx/index.tsx @@ -0,0 +1,73 @@ +import { useCallback, useContext } from 'react' +import { SendNFTsButton, SendTokensButton, TxBuilderButton } from '@/components/tx-flow/common/TxButton' +import { Container, Grid, Paper, SvgIcon, Typography } from '@mui/material' +import { TxModalContext } from '../../' +import TokenTransferFlow from '../TokenTransfer' +import AssetsIcon from '@/public/images/sidebar/assets.svg' +import { useTxBuilderApp } from '@/hooks/safe-apps/useTxBuilderApp' +import { ProgressBar } from '@/components/common/ProgressBar' +import ChainIndicator from '@/components/common/ChainIndicator' +import NewTxIcon from '@/public/images/transactions/new-tx.svg' + +import css from './styles.module.css' + +const NewTxMenu = () => { + const txBuilder = useTxBuilderApp() + const { setTxFlow } = useContext(TxModalContext) + + const onTokensClick = useCallback(() => { + setTxFlow() + }, [setTxFlow]) + + const progress = 10 + + return ( + + + {/* Alignment of `TxLayout` */} + + + + + + + + +
+ +
+ + + New transaction + +
+ + + + + Assets + + + + + + + {txBuilder?.app && ( + <> + + {txBuilder.app.name} Contract + interaction + + + + + )} + +
+
+
+
+ ) +} + +export default NewTxMenu diff --git a/src/components/tx-flow/flows/NewTx/styles.module.css b/src/components/tx-flow/flows/NewTx/styles.module.css new file mode 100644 index 0000000000..b6fb3f8456 --- /dev/null +++ b/src/components/tx-flow/flows/NewTx/styles.module.css @@ -0,0 +1,59 @@ +.container { + margin-top: 50px; +} + +.chain { + align-self: flex-end; + margin-bottom: var(--space-2); +} + +.pane { + display: flex; + flex-direction: column; + justify-content: center; + padding: var(--space-10) var(--space-8); + gap: var(--space-3); +} + +.title { + font-size: 44px; + font-weight: 700; +} + +.type { + font-weight: 700; + display: flex; + align-items: center; + gap: var(--space-1); +} + +.globs > div { + padding: 0; + margin: 0 0 var(--space-3) 0; +} + +@media (max-width: 899.95px) { + .container { + margin-top: var(--space-3); + padding: 0; + } + + .container :global(.MuiPaper-root) { + border-radius: unset; + } + + .chain { + position: absolute; + top: 0; + right: 57px; + margin: var(--space-2); + } + + .progressBar { + display: none; + } + + .pane + .pane { + padding-top: 0; + } +} diff --git a/src/components/tx-flow/flows/NftTransfer/ReviewNftBatch.tsx b/src/components/tx-flow/flows/NftTransfer/ReviewNftBatch.tsx new file mode 100644 index 0000000000..c9024eb02f --- /dev/null +++ b/src/components/tx-flow/flows/NftTransfer/ReviewNftBatch.tsx @@ -0,0 +1,60 @@ +import { type ReactElement, useEffect, useContext } from 'react' +import { Grid, Typography } from '@mui/material' +import SendToBlock from '@/components/tx-flow/flows/TokenTransfer/SendToBlock' +import { createNftTransferParams } from '@/services/tx/tokenTransferParams' +import type { NftTransferParams } from '.' +import useSafeAddress from '@/hooks/useSafeAddress' +import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +import { SafeTxContext } from '../../SafeTxProvider' +import { NftItems } from '@/components/tx-flow/flows/NftTransfer/SendNftBatch' + +type ReviewNftBatchProps = { + params: NftTransferParams + onSubmit: () => void + txNonce?: number +} + +const ReviewNftBatch = ({ params, onSubmit, txNonce }: ReviewNftBatchProps): ReactElement => { + const { setSafeTx, setSafeTxError, setNonce } = useContext(SafeTxContext) + const safeAddress = useSafeAddress() + const { tokens } = params + + useEffect(() => { + if (txNonce !== undefined) { + setNonce(txNonce) + } + }, [txNonce, setNonce]) + + useEffect(() => { + if (!safeAddress) return + + const calls = params.tokens.map((token) => { + return createNftTransferParams(safeAddress, params.recipient, token.id, token.address) + }) + + const promise = calls.length > 1 ? createMultiSendCallOnlyTx(calls) : createTx(calls[0]) + + promise.then(setSafeTx).catch(setSafeTxError) + }, [safeAddress, params, setSafeTx, setSafeTxError]) + + return ( + + + + + Send + + + + + + + + + + + ) +} + +export default ReviewNftBatch diff --git a/src/components/tx-flow/flows/NftTransfer/SendNftBatch.tsx b/src/components/tx-flow/flows/NftTransfer/SendNftBatch.tsx new file mode 100644 index 0000000000..3aeee4fab7 --- /dev/null +++ b/src/components/tx-flow/flows/NftTransfer/SendNftBatch.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { Box, Button, CardActions, Divider, FormControl, Grid, SvgIcon, Typography } from '@mui/material' +import { type SafeCollectibleResponse } from '@safe-global/safe-gateway-typescript-sdk' +import { FormProvider, useForm } from 'react-hook-form' +import NftIcon from '@/public/images/common/nft.svg' +import AddressBookInput from '@/components/common/AddressBookInput' +import type { NftTransferParams } from '.' +import ImageFallback from '@/components/common/ImageFallback' +import useAddressBook from '@/hooks/useAddressBook' +import TxCard from '../../common/TxCard' +import AddressInputReadOnly from '@/components/common/AddressInputReadOnly' +import commonCss from '@/components/tx-flow/common/styles.module.css' + +enum Field { + recipient = 'recipient', +} + +type FormData = Pick + +type SendNftBatchProps = { + onSubmit: (data: NftTransferParams) => void + params: NftTransferParams +} + +const NftItem = ({ image, name, description }: { image: string; name: string; description?: string }) => ( + + + + } + alt={name} + height={40} + /> + + + + + + {name} + + + {description && ( + + {description} + + )} + + +) + +export const NftItems = ({ tokens }: { tokens: SafeCollectibleResponse[] }) => { + return ( + + {tokens.map((token) => ( + + ))} + + ) +} + +const SendNftBatch = ({ params, onSubmit }: SendNftBatchProps) => { + const [recipientFocus, setRecipientFocus] = useState(false) + const addressBook = useAddressBook() + const { tokens } = params + + const formMethods = useForm({ + defaultValues: { + [Field.recipient]: params.recipient, + }, + }) + const { + handleSubmit, + watch, + setValue, + formState: { errors }, + } = formMethods + + const recipient = watch(Field.recipient) + const isAddressValid = !!recipient && !errors[Field.recipient] + + const onFormSubmit = (data: FormData) => { + onSubmit({ + recipient: data.recipient, + tokens, + }) + } + + return ( + + +
+ + {/* TODO: Extract this */} + {addressBook[recipient] ? ( + { + setValue(Field.recipient, '') + setRecipientFocus(true) + }} + > + + + ) : ( + + )} + + + + Selected NFTs + + + + + + + + + + +
+
+ ) +} + +export default SendNftBatch diff --git a/src/components/tx-flow/flows/NftTransfer/index.tsx b/src/components/tx-flow/flows/NftTransfer/index.tsx new file mode 100644 index 0000000000..308357118c --- /dev/null +++ b/src/components/tx-flow/flows/NftTransfer/index.tsx @@ -0,0 +1,47 @@ +import type { SafeCollectibleResponse } from '@safe-global/safe-gateway-typescript-sdk' +import NftIcon from '@/public/images/common/nft.svg' +import TxLayout from '@/components/tx-flow/common/TxLayout' +import useTxStepper from '../../useTxStepper' +import SendNftBatch from './SendNftBatch' +import ReviewNftBatch from './ReviewNftBatch' + +export type NftTransferParams = { + recipient: string + tokens: SafeCollectibleResponse[] +} + +type NftTransferFlowProps = Partial & { + txNonce?: number +} + +const defaultParams: NftTransferParams = { + recipient: '', + tokens: [], +} + +const NftTransferFlow = ({ txNonce, ...params }: NftTransferFlowProps) => { + const { data, step, nextStep, prevStep } = useTxStepper({ + ...defaultParams, + ...params, + }) + + const steps = [ + nextStep({ ...data, ...formData })} />, + + null} />, + ] + + return ( + + {steps} + + ) +} + +export default NftTransferFlow diff --git a/src/components/tx/modals/RejectTxModal/RejectTx.tsx b/src/components/tx-flow/flows/RejectTx/RejectTx.tsx similarity index 61% rename from src/components/tx/modals/RejectTxModal/RejectTx.tsx rename to src/components/tx-flow/flows/RejectTx/RejectTx.tsx index 9be392c2e8..cc1a765294 100644 --- a/src/components/tx/modals/RejectTxModal/RejectTx.tsx +++ b/src/components/tx-flow/flows/RejectTx/RejectTx.tsx @@ -1,23 +1,25 @@ import type { ReactElement } from 'react' import { Typography } from '@mui/material' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' - -import useAsync from '@/hooks/useAsync' import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { createRejectTx } from '@/services/tx/tx-sender' +import { useContext, useEffect } from 'react' +import { SafeTxContext } from '../../SafeTxProvider' type RejectTxProps = { txNonce: number - onSubmit: () => void } -const RejectTx = ({ txNonce, onSubmit }: RejectTxProps): ReactElement => { - const [rejectTx, rejectError] = useAsync(() => { - return createRejectTx(txNonce) - }, [txNonce]) +const RejectTx = ({ txNonce }: RejectTxProps): ReactElement => { + const { setSafeTx, setSafeTxError, setNonce } = useContext(SafeTxContext) + + useEffect(() => { + setNonce(txNonce) + + createRejectTx(txNonce).then(setSafeTx).catch(setSafeTxError) + }, [txNonce, setNonce, setSafeTx, setSafeTxError]) return ( - + {}}> To reject the transaction, a separate rejection transaction will be created to replace the original one. diff --git a/src/components/tx-flow/flows/RejectTx/index.tsx b/src/components/tx-flow/flows/RejectTx/index.tsx new file mode 100644 index 0000000000..6d6702c046 --- /dev/null +++ b/src/components/tx-flow/flows/RejectTx/index.tsx @@ -0,0 +1,17 @@ +import type { ReactElement } from 'react' +import TxLayout from '../../common/TxLayout' +import RejectTx from './RejectTx' + +type RejectTxProps = { + txNonce: number +} + +const RejectTxFlow = ({ txNonce }: RejectTxProps): ReactElement => { + return ( + + + + ) +} + +export default RejectTxFlow diff --git a/src/components/settings/TransactionGuards/RemoveGuard/steps/ReviewRemoveGuard.tsx b/src/components/tx-flow/flows/RemoveGuard/ReviewRemoveGuard.tsx similarity index 58% rename from src/components/settings/TransactionGuards/RemoveGuard/steps/ReviewRemoveGuard.tsx rename to src/components/tx-flow/flows/RemoveGuard/ReviewRemoveGuard.tsx index 11abaf6d9d..e2155eb66e 100644 --- a/src/components/settings/TransactionGuards/RemoveGuard/steps/ReviewRemoveGuard.tsx +++ b/src/components/tx-flow/flows/RemoveGuard/ReviewRemoveGuard.tsx @@ -1,19 +1,19 @@ -import { useEffect } from 'react' +import { useContext, useEffect } from 'react' import { Typography } from '@mui/material' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' - -import useAsync from '@/hooks/useAsync' import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import EthHashInfo from '@/components/common/EthHashInfo' -import type { RemoveGuardData } from '@/components/settings/TransactionGuards/RemoveGuard' import { Errors, logError } from '@/services/exceptions' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' import { createRemoveGuardTx } from '@/services/tx/tx-sender' +import { type RemoveGuardFlowProps } from '.' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' + +export const ReviewRemoveGuard = ({ params }: { params: RemoveGuardFlowProps }) => { + const { setSafeTx, safeTxError, setSafeTxError } = useContext(SafeTxContext) -export const ReviewRemoveGuard = ({ data, onSubmit }: { data: RemoveGuardData; onSubmit: () => void }) => { - const [safeTx, safeTxError] = useAsync(() => { - return createRemoveGuardTx() - }, []) + useEffect(() => { + createRemoveGuardTx().then(setSafeTx).catch(setSafeTxError) + }, [setSafeTx, setSafeTxError]) useEffect(() => { if (safeTxError) { @@ -23,14 +23,14 @@ export const ReviewRemoveGuard = ({ data, onSubmit }: { data: RemoveGuardData; o const onFormSubmit = () => { trackEvent(SETTINGS_EVENTS.MODULES.REMOVE_GUARD) - - onSubmit() } return ( - + ({ color: palette.primary.light })}>Transaction guard - + + + Once the transaction guard has been removed, checks by the transaction guard will not be conducted before or after any subsequent transactions. diff --git a/src/components/tx-flow/flows/RemoveGuard/index.tsx b/src/components/tx-flow/flows/RemoveGuard/index.tsx new file mode 100644 index 0000000000..2e96e6b471 --- /dev/null +++ b/src/components/tx-flow/flows/RemoveGuard/index.tsx @@ -0,0 +1,17 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import { ReviewRemoveGuard } from '@/components/tx-flow/flows/RemoveGuard/ReviewRemoveGuard' + +// TODO: This can possibly be combined with the remove module type +export type RemoveGuardFlowProps = { + address: string +} + +const RemoveGuardFlow = ({ address }: RemoveGuardFlowProps) => { + return ( + + + + ) +} + +export default RemoveGuardFlow diff --git a/src/components/tx-flow/flows/RemoveModule/ReviewRemoveModule.tsx b/src/components/tx-flow/flows/RemoveModule/ReviewRemoveModule.tsx new file mode 100644 index 0000000000..01a11b223d --- /dev/null +++ b/src/components/tx-flow/flows/RemoveModule/ReviewRemoveModule.tsx @@ -0,0 +1,44 @@ +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +import { Grid, Typography } from '@mui/material' +import { useContext, useEffect } from 'react' +import { Errors, logError } from '@/services/exceptions' +import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' +import { createRemoveModuleTx } from '@/services/tx/tx-sender' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { type RemoveModuleFlowProps } from '.' +import EthHashInfo from '@/components/common/EthHashInfo' + +export const ReviewRemoveModule = ({ params }: { params: RemoveModuleFlowProps }) => { + const { setSafeTx, safeTxError, setSafeTxError } = useContext(SafeTxContext) + + useEffect(() => { + createRemoveModuleTx(params.address).then(setSafeTx).catch(setSafeTxError) + }, [params.address, setSafeTx, setSafeTxError]) + + useEffect(() => { + if (safeTxError) { + logError(Errors._806, safeTxError.message) + } + }, [safeTxError]) + + const onFormSubmit = () => { + trackEvent(SETTINGS_EVENTS.MODULES.REMOVE_MODULE) + } + + return ( + + + + Module + + + + + + + After removing this module, any feature or app that uses this module might no longer work. If this Safe Account + requires more then one signature, the module removal will have to be confirmed by other owners as well. + + + ) +} diff --git a/src/components/tx-flow/flows/RemoveModule/index.tsx b/src/components/tx-flow/flows/RemoveModule/index.tsx new file mode 100644 index 0000000000..5332d8c0c5 --- /dev/null +++ b/src/components/tx-flow/flows/RemoveModule/index.tsx @@ -0,0 +1,16 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import { ReviewRemoveModule } from './ReviewRemoveModule' + +export type RemoveModuleFlowProps = { + address: string +} + +const RemoveModuleFlow = ({ address }: RemoveModuleFlowProps) => { + return ( + + + + ) +} + +export default RemoveModuleFlow diff --git a/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx b/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx new file mode 100644 index 0000000000..3c59f4b35c --- /dev/null +++ b/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx @@ -0,0 +1,61 @@ +import { useContext, useEffect } from 'react' +import { Typography, Divider, Box, Paper, SvgIcon } from '@mui/material' +import { EthHashInfo } from '@safe-global/safe-react-components' +import type { ReactElement } from 'react' + +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +import useAddressBook from '@/hooks/useAddressBook' +import useSafeInfo from '@/hooks/useSafeInfo' +import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' +import { createRemoveOwnerTx } from '@/services/tx/tx-sender' +import MinusIcon from '@/public/images/common/minus.svg' +import { SafeTxContext } from '../../SafeTxProvider' +import type { RemoveOwnerFlowProps } from '.' + +import commonCss from '@/components/tx-flow/common/styles.module.css' + +export const ReviewRemoveOwner = ({ params }: { params: RemoveOwnerFlowProps }): ReactElement => { + const addressBook = useAddressBook() + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) + const { safe } = useSafeInfo() + const { removedOwner, threshold } = params + + useEffect(() => { + createRemoveOwnerTx({ ownerAddress: removedOwner.address, threshold }).then(setSafeTx).catch(setSafeTxError) + }, [removedOwner.address, setSafeTx, setSafeTxError, threshold]) + + const newOwnerLength = safe.owners.length - 1 + + const onFormSubmit = () => { + trackEvent({ ...SETTINGS_EVENTS.SETUP.THRESHOLD, label: safe.threshold }) + trackEvent({ ...SETTINGS_EVENTS.SETUP.OWNERS, label: safe.owners.length }) + } + + return ( + + palette.warning.background, p: 2 }}> + + + Selected owner + + + + + + + Any transaction requires the confirmation of: + + + {threshold} out of {newOwnerLength} owners + + + + + ) +} diff --git a/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx b/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx new file mode 100644 index 0000000000..942fbdc0ae --- /dev/null +++ b/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react' +import { Button, Box, CardActions, Divider, Grid, MenuItem, Select, Typography, SvgIcon, Tooltip } from '@mui/material' +import type { ReactElement, SyntheticEvent } from 'react' +import type { SelectChangeEvent } from '@mui/material' + +import EthHashInfo from '@/components/common/EthHashInfo' +import useSafeInfo from '@/hooks/useSafeInfo' +import TxCard from '../../common/TxCard' +import InfoIcon from '@/public/images/notifications/info.svg' +import { TOOLTIP_TITLES } from '@/components/tx-flow/common/constants' +import type { RemoveOwnerFlowProps } from '.' + +import commonCss from '@/components/tx-flow/common/styles.module.css' + +export const SetThreshold = ({ + params, + onSubmit, +}: { + params: RemoveOwnerFlowProps + onSubmit: (data: RemoveOwnerFlowProps) => void +}): ReactElement => { + const { safe } = useSafeInfo() + const [selectedThreshold, setSelectedThreshold] = useState(params.threshold || 1) + + const handleChange = (event: SelectChangeEvent) => { + setSelectedThreshold(parseInt(event.target.value.toString())) + } + + const onSubmitHandler = (e: SyntheticEvent) => { + e.preventDefault() + onSubmit({ ...params, threshold: selectedThreshold }) + } + + const newNumberOfOwners = safe ? safe.owners.length - 1 : 1 + + return ( + +
+ + Review the owner you want to remove from the active Safe Account: + {/* TODO: Update the EthHashInfo style from the replace owner PR */} + + + + + + + + Threshold + + + + + + + Any transaction requires the confirmation of: + + + + + + out of {newNumberOfOwners} owner(s) + + + + + + + + + + +
+ ) +} diff --git a/src/components/tx-flow/flows/RemoveOwner/index.tsx b/src/components/tx-flow/flows/RemoveOwner/index.tsx new file mode 100644 index 0000000000..37e803442d --- /dev/null +++ b/src/components/tx-flow/flows/RemoveOwner/index.tsx @@ -0,0 +1,46 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import useSafeInfo from '@/hooks/useSafeInfo' +import useTxStepper from '../../useTxStepper' +import { ReviewRemoveOwner } from './ReviewRemoveOwner' +import SaveAddressIcon from '@/public/images/common/save-address.svg' +import { SetThreshold } from './SetThreshold' + +type Owner = { + address: string + name?: string +} + +export type RemoveOwnerFlowProps = { + removedOwner: Owner + threshold: number +} + +const RemoveOwnerFlow = (props: Owner) => { + const { safe } = useSafeInfo() + + const defaultValues: RemoveOwnerFlowProps = { + removedOwner: props, + threshold: Math.min(safe.threshold, safe.owners.length - 1), + } + + const { data, step, nextStep, prevStep } = useTxStepper(defaultValues) + + const steps = [ + nextStep({ ...data, ...formData })} />, + , + ] + + return ( + + {steps} + + ) +} + +export default RemoveOwnerFlow diff --git a/src/components/tx-flow/flows/RemoveOwner/styles.module.css b/src/components/tx-flow/flows/RemoveOwner/styles.module.css new file mode 100644 index 0000000000..ddde4a925a --- /dev/null +++ b/src/components/tx-flow/flows/RemoveOwner/styles.module.css @@ -0,0 +1,6 @@ +.action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 20px; +} diff --git a/src/components/tx-flow/flows/RemoveSpendingLimit/RemoveSpendingLimit.tsx b/src/components/tx-flow/flows/RemoveSpendingLimit/RemoveSpendingLimit.tsx new file mode 100644 index 0000000000..df046f6cc6 --- /dev/null +++ b/src/components/tx-flow/flows/RemoveSpendingLimit/RemoveSpendingLimit.tsx @@ -0,0 +1,91 @@ +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +import { getSpendingLimitInterface, getSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts' +import useChainId from '@/hooks/useChainId' +import { useContext, useEffect } from 'react' +import { SafeTxContext } from '../../SafeTxProvider' +import EthHashInfo from '@/components/common/EthHashInfo' +import { Grid, Typography } from '@mui/material' +import type { SpendingLimitState } from '@/store/spendingLimitsSlice' +import { relativeTime } from '@/utils/date' +import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' +import useBalances from '@/hooks/useBalances' +import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock' +import { safeFormatUnits } from '@/utils/formatters' +import SpendingLimitLabel from '@/components/common/SpendingLimitLabel' +import { createTx } from '@/services/tx/tx-sender' + +export const RemoveSpendingLimit = ({ params }: { params: SpendingLimitState }) => { + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) + const chainId = useChainId() + const { balances } = useBalances() + const token = balances.items.find((item) => item.tokenInfo.address === params.token.address) + + useEffect(() => { + const spendingLimitAddress = getSpendingLimitModuleAddress(chainId) + + if (!spendingLimitAddress) { + return + } + + const spendingLimitInterface = getSpendingLimitInterface() + const txData = spendingLimitInterface.encodeFunctionData('deleteAllowance', [ + params.beneficiary, + params.token.address, + ]) + + const txParams = { + to: spendingLimitAddress, + value: '0', + data: txData, + } + + createTx(txParams).then(setSafeTx).catch(setSafeTxError) + }, [chainId, params.beneficiary, params.token, setSafeTx, setSafeTxError]) + + const onFormSubmit = () => { + trackEvent(SETTINGS_EVENTS.SPENDING_LIMIT.LIMIT_REMOVED) + } + + return ( + + {token && ( + + )} + + + + + Beneficiary + + + + + + + + + + + Reset time + + + + + + + + ) +} diff --git a/src/components/tx-flow/flows/RemoveSpendingLimit/index.tsx b/src/components/tx-flow/flows/RemoveSpendingLimit/index.tsx new file mode 100644 index 0000000000..64f377e3e0 --- /dev/null +++ b/src/components/tx-flow/flows/RemoveSpendingLimit/index.tsx @@ -0,0 +1,14 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import { RemoveSpendingLimit } from './RemoveSpendingLimit' +import type { SpendingLimitState } from '@/store/spendingLimitsSlice' +import SaveAddressIcon from '@/public/images/common/save-address.svg' + +const RemoveSpendingLimitFlow = ({ spendingLimit }: { spendingLimit: SpendingLimitState }) => { + return ( + + + + ) +} + +export default RemoveSpendingLimitFlow diff --git a/src/components/tx-flow/flows/ReplaceOwner/index.tsx b/src/components/tx-flow/flows/ReplaceOwner/index.tsx new file mode 100644 index 0000000000..a8db411808 --- /dev/null +++ b/src/components/tx-flow/flows/ReplaceOwner/index.tsx @@ -0,0 +1,53 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import useTxStepper from '@/components/tx-flow/useTxStepper' +import useSafeInfo from '@/hooks/useSafeInfo' +import { ReviewOwner } from '../AddOwner/ReviewOwner' +import { ChooseOwner, ChooseOwnerMode } from '../AddOwner/ChooseOwner' +import SaveAddressIcon from '@/public/images/common/save-address.svg' + +type Owner = { + address: string + name?: string +} + +export type ReplaceOwnerFlowProps = { + newOwner: Owner + removedOwner: Owner + threshold: number +} + +const ReplaceOwnerFlow = ({ address }: { address: string }) => { + const { safe } = useSafeInfo() + + const defaultValues: ReplaceOwnerFlowProps = { + newOwner: { address: '' }, + removedOwner: { address }, + threshold: safe.threshold, + } + + const { data, step, nextStep, prevStep } = useTxStepper(defaultValues) + + const steps = [ + nextStep({ ...data, ...formData })} + mode={ChooseOwnerMode.REPLACE} + />, + , + ] + + return ( + + {steps} + + ) +} + +export default ReplaceOwnerFlow diff --git a/src/components/tx-flow/flows/ReplaceTx/index.tsx b/src/components/tx-flow/flows/ReplaceTx/index.tsx new file mode 100644 index 0000000000..6a36c47f6b --- /dev/null +++ b/src/components/tx-flow/flows/ReplaceTx/index.tsx @@ -0,0 +1,117 @@ +import { Box, Button, SvgIcon, Tooltip, Typography } from '@mui/material' + +import InfoIcon from '@/public/images/notifications/info.svg' +import ReplaceTxIcon from '@/public/images/transactions/replace-tx.svg' +import { SendTokensButton } from '@/components/tx-flow/common/TxButton' +import { useQueuedTxByNonce } from '@/hooks/useTxQueue' +import { isCustomTxInfo } from '@/utils/transaction-guards' + +import css from './styles.module.css' +import { useContext } from 'react' +import { TxModalContext } from '../..' +import TokenTransferFlow from '../TokenTransfer' +import RejectTx from '../RejectTx' +import TxLayout from '@/components/tx-flow/common/TxLayout' +import TxCard from '@/components/tx-flow/common/TxCard' + +// TODO: Move this to the status widget +/* + +const wrapIcon = (icon: React.ReactNode) =>
{icon}
+const steps = [ + { + label: 'Create new transaction with same nonce', + icon:
, + }, + { + label: 'Collect confirmations from owners', + icon: wrapIcon(), + }, + { + label: 'Execute replacement transaction', + icon: wrapIcon(), + }, + { + label: 'Initial transaction is replaced', + icon: wrapIcon(), + }, +] + */ + +const btnWidth = { + width: { + xs: 240, + sm: '100%', + }, +} + +const ReplaceTxMenu = ({ txNonce }: { txNonce: number }) => { + const { setTxFlow } = useContext(TxModalContext) + const queuedTxsByNonce = useQueuedTxByNonce(txNonce) + const canCancel = !queuedTxsByNonce?.some( + (item) => isCustomTxInfo(item.transaction.txInfo) && item.transaction.txInfo.isCancellation, + ) + + return ( + + + + + + + Select how you would like to replace this transaction + + + A signed transaction cannot be removed but it can be replaced with a new transaction with the same nonce. + + +
+
+ setTxFlow()} sx={btnWidth} /> +
+ + + or + + +
+ + + + + + + + + + + +
+
+
+
+ ) +} + +export default ReplaceTxMenu diff --git a/src/components/tx/modals/NewTxModal/styles.module.css b/src/components/tx-flow/flows/ReplaceTx/styles.module.css similarity index 81% rename from src/components/tx/modals/NewTxModal/styles.module.css rename to src/components/tx-flow/flows/ReplaceTx/styles.module.css index 27968e698d..e007144b9c 100644 --- a/src/components/tx/modals/NewTxModal/styles.module.css +++ b/src/components/tx-flow/flows/ReplaceTx/styles.module.css @@ -1,8 +1,4 @@ .container { - display: flex; - flex-direction: column; - align-items: center; - padding: var(--space-4) !important; } .redCircle { @@ -44,11 +40,28 @@ .or { text-align: center; - font-weight: 700; padding: var(--space-2) var(--space-3); } -@media (max-width: 600px) { +.buttons { + display: flex; + align-items: center; + justify-content: center; +} + +.rejectButton { + position: relative; + display: flex; + align-items: center; +} + +.rejectHint { + position: absolute; + right: calc(-1 * var(--space-3)); + z-index: 1; +} + +@media (max-width: 599.95px) { .container { padding: var(--space-3) !important; } @@ -79,4 +92,8 @@ .or { padding: var(--space-1) var(--space-2); } + + .buttons { + flex-direction: column; + } } diff --git a/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx b/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx new file mode 100644 index 0000000000..d83a5d33ef --- /dev/null +++ b/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx @@ -0,0 +1,85 @@ +import { useContext, useEffect, useMemo, useState } from 'react' +import type { ReactElement } from 'react' +import { ErrorBoundary } from '@sentry/react' +import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import SendToBlock from '@/components/tx-flow/flows/TokenTransfer/SendToBlock' +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +import { useCurrentChain } from '@/hooks/useChains' +import type { SafeAppsTxParams } from '.' +import { trackSafeAppTxCount } from '@/services/safe-apps/track-app-usage-count' +import { getTxOrigin } from '@/utils/transactions' +import { createMultiSendCallOnlyTx, createTx, dispatchSafeAppsTx } from '@/services/tx/tx-sender' +import useOnboard from '@/hooks/wallets/useOnboard' +import useSafeInfo from '@/hooks/useSafeInfo' +import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import ApprovalEditor from '@/components/tx/ApprovalEditor' +import { getInteractionTitle, isTxValid } from '@/components/safe-apps/utils' +import ErrorMessage from '@/components/tx/ErrorMessage' +import { asError } from '@/services/exceptions/utils' + +type ReviewSafeAppsTxProps = { + safeAppsTx: SafeAppsTxParams +} + +const ReviewSafeAppsTx = ({ + safeAppsTx: { txs, requestId, params, appId, app }, +}: ReviewSafeAppsTxProps): ReactElement => { + const { safe } = useSafeInfo() + const onboard = useOnboard() + const chain = useCurrentChain() + const [txList, setTxList] = useState(txs) + const { safeTx, setSafeTx, safeTxError, setSafeTxError } = useContext(SafeTxContext) + + useHighlightHiddenTab() + + useEffect(() => { + const createSafeTx = async (): Promise => { + const isMultiSend = txList.length > 1 + const tx = isMultiSend ? await createMultiSendCallOnlyTx(txList) : await createTx(txList[0]) + + if (params?.safeTxGas) { + // FIXME: do it properly via the Core SDK + // @ts-expect-error safeTxGas readonly + tx.data.safeTxGas = params.safeTxGas + } + + return tx + } + + createSafeTx().then(setSafeTx).catch(setSafeTxError) + }, [txList, setSafeTx, setSafeTxError, params]) + + const handleSubmit = async () => { + if (!safeTx || !onboard) return + trackSafeAppTxCount(Number(appId)) + + try { + await dispatchSafeAppsTx(safeTx, requestId, onboard, safe.chainId) + } catch (error) { + setSafeTxError(asError(error)) + } + } + + const origin = useMemo(() => getTxOrigin(app), [app]) + const error = !isTxValid(txs) + + return ( + + Error parsing data
}> + + + + {safeTx ? ( + + ) : error ? ( + + This Safe App initiated a transaction which cannot be processed. Please get in touch with the developer of + this Safe App for more information. + + ) : null} +
+ ) +} + +export default ReviewSafeAppsTx diff --git a/src/components/tx-flow/flows/SafeAppsTx/index.tsx b/src/components/tx-flow/flows/SafeAppsTx/index.tsx new file mode 100644 index 0000000000..fa778d01aa --- /dev/null +++ b/src/components/tx-flow/flows/SafeAppsTx/index.tsx @@ -0,0 +1,27 @@ +import type { BaseTransaction, RequestId, SendTransactionRequestParams } from '@safe-global/safe-apps-sdk' +import TxLayout from '@/components/tx-flow/common/TxLayout' +import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' +import ReviewSafeAppsTx from './ReviewSafeAppsTx' +import { AppTitle } from '@/components/tx-flow/flows/SignMessage' + +export type SafeAppsTxParams = { + appId?: string + app?: SafeAppData + requestId: RequestId + txs: BaseTransaction[] + params?: SendTransactionRequestParams +} + +const SafeAppsTxFlow = ({ data }: { data: SafeAppsTxParams }) => { + return ( + } + step={0} + > + + + ) +} + +export default SafeAppsTxFlow diff --git a/src/components/safe-messages/MsgModal/index.test.tsx b/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx similarity index 89% rename from src/components/safe-messages/MsgModal/index.test.tsx rename to src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx index d44c40b44e..e11fb881c2 100644 --- a/src/components/safe-messages/MsgModal/index.test.tsx +++ b/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx @@ -2,7 +2,7 @@ import { hexlify, hexZeroPad, toUtf8Bytes } from 'ethers/lib/utils' import type { ChainInfo, SafeInfo, SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import { SafeMessageListItemType } from '@safe-global/safe-gateway-typescript-sdk' -import MsgModal from '@/components/safe-messages/MsgModal' +import SignMessage from './SignMessage' import * as useIsWrongChainHook from '@/hooks/useIsWrongChain' import * as useIsSafeOwnerHook from '@/hooks/useIsSafeOwner' import * as useWalletHook from '@/hooks/wallets/useWallet' @@ -67,7 +67,7 @@ const mockOnboard = { }, } as unknown as OnboardAPI -describe('MsgModal', () => { +describe('SignMessage', () => { beforeEach(() => { jest.clearAllMocks() @@ -94,12 +94,11 @@ describe('MsgModal', () => { it('renders the (decoded) message', () => { const { getByText } = render( - , ) @@ -108,13 +107,7 @@ describe('MsgModal', () => { it('displays the SafeMessage message', () => { const { getByText } = render( - , + , ) expect(getByText('0xaa05af77f274774b8bdc7b61d98bc40da523dc2821fdea555f4d6aa413199bcc')).toBeInTheDocument() @@ -122,13 +115,7 @@ describe('MsgModal', () => { it('generates the SafeMessage hash if not provided', () => { const { getByText } = render( - , + , ) expect(getByText('0x73d0948ac608c5d00a6dd26dd396cce79b459307ea365f5a5bd5d3119c2d9708')).toBeInTheDocument() @@ -176,13 +163,7 @@ describe('MsgModal', () => { it('renders the message', () => { const { getByText } = render( - , + , ) Object.keys(EXAMPLE_MESSAGE.message).forEach((key) => { @@ -194,13 +175,7 @@ describe('MsgModal', () => { it('displays the SafeMessage message', () => { const { getByText } = render( - , + , ) expect(getByText('0xd5ffe9f6faa9cc9294673fb161b1c7b3e0c98241e90a38fc6c451941f577fb19')).toBeInTheDocument() @@ -208,13 +183,7 @@ describe('MsgModal', () => { it('generates the SafeMessage hash if not provided', () => { const { getByText } = render( - , + , ) expect(getByText('0x10c926c4f417e445de3fddc7ad8c864f81b9c81881b88eba646015de10d21613')).toBeInTheDocument() @@ -228,12 +197,11 @@ describe('MsgModal', () => { jest.spyOn(useAsyncHook, 'default').mockReturnValue([undefined, new Error('SafeMessage not found'), false]) const { getByText } = render( - , ) @@ -300,13 +268,7 @@ describe('MsgModal', () => { jest.spyOn(useSafeMessages, 'useSafeMessage').mockReturnValue(msg) const { getByText } = render( - , + , ) await act(async () => { @@ -343,12 +305,11 @@ describe('MsgModal', () => { jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => false) const { getByText } = render( - , ) @@ -365,12 +326,11 @@ describe('MsgModal', () => { jest.spyOn(useChainsHook, 'useCurrentChain').mockReturnValue({ chainName: 'Goerli' } as ChainInfo) const { getByText } = render( - , ) @@ -391,12 +351,11 @@ describe('MsgModal', () => { jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => false) const { getByText } = render( - , ) @@ -445,13 +404,7 @@ describe('MsgModal', () => { jest.spyOn(useSafeMessages, 'useSafeMessage').mockReturnValue(msg) const { getByText } = render( - , + , ) await waitFor(() => { @@ -479,12 +432,11 @@ describe('MsgModal', () => { .mockImplementation(() => Promise.reject(new Error('Test error'))) const { getByText } = render( - , ) @@ -521,12 +473,11 @@ describe('MsgModal', () => { .mockImplementation(() => Promise.reject(new Error('Test error'))) const { getByText } = render( - , ) diff --git a/src/components/safe-messages/MsgModal/index.tsx b/src/components/tx-flow/flows/SignMessage/SignMessage.tsx similarity index 54% rename from src/components/safe-messages/MsgModal/index.tsx rename to src/components/tx-flow/flows/SignMessage/SignMessage.tsx index ab9ab83aef..9e9f7b788b 100644 --- a/src/components/safe-messages/MsgModal/index.tsx +++ b/src/components/tx-flow/flows/SignMessage/SignMessage.tsx @@ -1,13 +1,10 @@ -import { Grid, DialogActions, Button, Box, Typography, DialogContent, SvgIcon } from '@mui/material' +import { Grid, Button, Box, Typography, SvgIcon, CardContent, CardActions } from '@mui/material' import { useTheme } from '@mui/material/styles' -import { useCallback, useState } from 'react' +import { useContext } from 'react' import { SafeMessageListItemType, SafeMessageStatus } from '@safe-global/safe-gateway-typescript-sdk' import type { ReactElement } from 'react' import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import type { RequestId } from '@safe-global/safe-apps-sdk' - -import ModalDialog, { ModalDialogTitle } from '@/components/common/ModalDialog' -import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard' import EthHashInfo from '@/components/common/EthHashInfo' import RequiredIcon from '@/public/images/messages/required.svg' import useSafeInfo from '@/hooks/useSafeInfo' @@ -17,21 +14,17 @@ import ErrorMessage from '@/components/tx/ErrorMessage' import useWallet from '@/hooks/wallets/useWallet' import { useSafeMessage } from '@/hooks/messages/useSafeMessages' import useOnboard, { switchWallet } from '@/hooks/wallets/useOnboard' - -import txStepperCss from '@/components/tx/TxStepper/styles.module.css' -import { DecodedMsg } from '../DecodedMsg' +import { TxModalContext } from '@/components/tx-flow' import CopyButton from '@/components/common/CopyButton' import { WrongChainWarning } from '@/components/tx/WrongChainWarning' import MsgSigners from '@/components/safe-messages/MsgSigners' -import { ConfirmationDialog } from './ConfirmationDialog' import useDecodedSafeMessage from '@/hooks/messages/useDecodedSafeMessage' import useSyncSafeMessageSigner from '@/hooks/messages/useSyncSafeMessageSigner' import SuccessMessage from '@/components/tx/SuccessMessage' -import InfoBox from '../InfoBox' import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab' - -const APP_LOGO_FALLBACK_IMAGE = '/images/apps/apps-icon.svg' -const APP_NAME_FALLBACK = 'Sign message off-chain' +import InfoBox from '@/components/safe-messages/InfoBox' +import { DecodedMsg } from '@/components/safe-messages/DecodedMsg' +import TxCard from '@/components/tx-flow/common/TxCard' const createSkeletonMessage = (confirmationsRequired: number): SafeMessage => { return { @@ -65,7 +58,7 @@ const MessageHashField = ({ label, hashValue }: { label: string; hashValue: stri const DialogHeader = ({ threshold }: { threshold: number }) => ( <> - + @@ -77,33 +70,6 @@ const DialogHeader = ({ threshold }: { threshold: number }) => ( ) -const DialogTitle = ({ - onClose, - name, - logoUri, -}: { - onClose: () => void - name: string | null - logoUri: string | null -}) => { - const appName = name || APP_NAME_FALLBACK - const appLogo = logoUri || APP_LOGO_FALLBACK_IMAGE - return ( - - - - - - - {appName} - - - - - - ) -} - const MessageDialogError = ({ isOwner, submitError }: { isOwner: boolean; submitError: Error | undefined }) => { const wallet = useWallet() const onboard = useOnboard() @@ -136,8 +102,8 @@ const AlreadySignedByOwnerMessage = ({ hasSigned }: { hasSigned: boolean }) => { } return ( - - + + Your connected wallet has already signed this message. @@ -150,32 +116,23 @@ const AlreadySignedByOwnerMessage = ({ hasSigned }: { hasSigned: boolean }) => { ) } -type BaseProps = { - onClose: () => void -} & Pick +type BaseProps = Pick // Custom Safe Apps do not have a `safeAppId` -type ProposeProps = BaseProps & { +export type ProposeProps = BaseProps & { safeAppId?: number requestId: RequestId } // A proposed message does not return the `safeAppId` but the `logoUri` and `name` of the Safe App that proposed it -type ConfirmProps = BaseProps & { +export type ConfirmProps = BaseProps & { safeAppId?: never requestId?: RequestId } -const MsgModal = ({ - onClose, - logoUri, - name, - message, - safeAppId, - requestId, -}: ProposeProps | ConfirmProps): ReactElement => { +const SignMessage = ({ message, safeAppId, requestId }: ProposeProps | ConfirmProps): ReactElement => { // Hooks & variables - const [showCloseTooltip, setShowCloseTooltip] = useState(false) + const { setTxFlow } = useContext(TxModalContext) const { palette } = useTheme() const { safe } = useSafeInfo() const isOwner = useIsSafeOwner() @@ -198,68 +155,58 @@ const MsgModal = ({ safeMessageHash, requestId, safeAppId, - onClose, + () => setTxFlow(undefined), ) - const handleClose = useCallback(() => { - if (requestId && (!ongoingMessage || ongoingMessage.status === SafeMessageStatus.NEEDS_CONFIRMATION)) { - // If we are in a Safe app modal we want to keep the modal open - setShowCloseTooltip(true) - } else { - onClose() - } - }, [onClose, ongoingMessage, requestId]) - return ( <> - -
- - - - - - - Message: - - - - - - - - - - - - - - - - - - - - - -
-
- setShowCloseTooltip(false)} onClose={onClose} /> + + + + + + Message: + + + + + + + + + + + + + + + + + + + + + + + + + ) } -export default MsgModal +export default SignMessage diff --git a/src/components/tx-flow/flows/SignMessage/index.tsx b/src/components/tx-flow/flows/SignMessage/index.tsx new file mode 100644 index 0000000000..d8d4de3a42 --- /dev/null +++ b/src/components/tx-flow/flows/SignMessage/index.tsx @@ -0,0 +1,35 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import SignMessage, { type ConfirmProps, type ProposeProps } from '@/components/tx-flow/flows/SignMessage/SignMessage' +import { Box, Typography } from '@mui/material' +import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard' + +const APP_LOGO_FALLBACK_IMAGE = '/images/apps/apps-icon.svg' +const APP_NAME_FALLBACK = 'Sign message off-chain' + +export const AppTitle = ({ name, logoUri }: { name?: string | null; logoUri?: string | null }) => { + const appName = name || APP_NAME_FALLBACK + const appLogo = logoUri || APP_LOGO_FALLBACK_IMAGE + return ( + + + + {appName} + + + ) +} + +const SignMessageFlow = ({ ...props }: ProposeProps | ConfirmProps) => { + return ( + } + step={0} + hideNonce + > + + + ) +} + +export default SignMessageFlow diff --git a/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx new file mode 100644 index 0000000000..83d265efc9 --- /dev/null +++ b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx @@ -0,0 +1,78 @@ +import { Methods } from '@safe-global/safe-apps-sdk' +import * as web3 from '@/hooks/wallets/web3' +import { Web3Provider } from '@ethersproject/providers' +import { render, screen } from '@/tests/test-utils' +import { SafeAppAccessPolicyTypes } from '@safe-global/safe-gateway-typescript-sdk' +import ReviewSignMessageOnChain from '@/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain' + +describe('ReviewSignMessageOnChain', () => { + test('can handle messages with EIP712Domain type in the JSON-RPC payload', () => { + jest.spyOn(web3, '_getWeb3').mockImplementation(() => new Web3Provider(jest.fn())) + + render( + , + ) + + expect(screen.getByText('Interact with SignMessageLib')).toBeInTheDocument() + }) +}) diff --git a/src/components/safe-apps/SafeAppsSignMessageModal/ReviewSafeAppsSignMessage.tsx b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx similarity index 61% rename from src/components/safe-apps/SafeAppsSignMessageModal/ReviewSafeAppsSignMessage.tsx rename to src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx index a22f775f6d..0d94b49352 100644 --- a/src/components/safe-apps/SafeAppsSignMessageModal/ReviewSafeAppsSignMessage.tsx +++ b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx @@ -1,12 +1,11 @@ import type { ReactElement } from 'react' -import { useState } from 'react' +import { useContext, useEffect } from 'react' import { useMemo } from 'react' import { hashMessage, _TypedDataEncoder } from 'ethers/lib/utils' import { Box } from '@mui/system' import { Typography, SvgIcon } from '@mui/material' import WarningIcon from '@/public/images/notifications/warning.svg' -import { isObjectEIP712TypedData, Methods } from '@safe-global/safe-apps-sdk' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { type EIP712TypedData, isObjectEIP712TypedData, Methods, type RequestId } from '@safe-global/safe-apps-sdk' import { OperationType } from '@safe-global/safe-core-sdk-types' import SendFromBlock from '@/components/tx/SendFromBlock' @@ -14,9 +13,7 @@ import { InfoDetails } from '@/components/transactions/InfoDetails' import EthHashInfo from '@/components/common/EthHashInfo' import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { generateDataRowValue } from '@/components/transactions/TxDetails/Summary/TxDataRow' -import type { SafeAppsSignMessageParams } from '@/components/safe-apps/SafeAppsSignMessageModal' import useChainId from '@/hooks/useChainId' -import useAsync from '@/hooks/useAsync' import { getReadOnlySignMessageLibContract } from '@/services/contracts/safeContracts' import { DecodedMsg } from '@/components/safe-messages/DecodedMsg' import CopyButton from '@/components/common/CopyButton' @@ -25,18 +22,23 @@ import { createTx, dispatchSafeAppsTx } from '@/services/tx/tx-sender' import useOnboard from '@/hooks/wallets/useOnboard' import useSafeInfo from '@/hooks/useSafeInfo' import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab' - -type ReviewSafeAppsSignMessageProps = { - safeAppsSignMessage: SafeAppsSignMessageParams +import { type SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { asError } from '@/services/exceptions/utils' + +export type SignMessageOnChainProps = { + appId?: number + app?: SafeAppData + requestId: RequestId + message: string | EIP712TypedData + method: Methods.signMessage | Methods.signTypedMessage } -const ReviewSafeAppsSignMessage = ({ - safeAppsSignMessage: { message, method, requestId }, -}: ReviewSafeAppsSignMessageProps): ReactElement => { +const ReviewSignMessageOnChain = ({ message, method, requestId }: SignMessageOnChainProps): ReactElement => { const chainId = useChainId() const { safe } = useSafeInfo() const onboard = useOnboard() - const [submitError, setSubmitError] = useState() + const { safeTx, setSafeTx, setSafeTxError } = useContext(SafeTxContext) useHighlightHiddenTab() @@ -56,7 +58,7 @@ const ReviewSafeAppsSignMessage = ({ return [] }, [isTextMessage, isTypedMessage, message]) - const [safeTx, safeTxError] = useAsync(() => { + useEffect(() => { let txData if (isTextMessage) { @@ -74,60 +76,66 @@ const ReviewSafeAppsSignMessage = ({ ]) } - return createTx({ + const params = { to: signMessageAddress, value: '0', data: txData || '0x', operation: OperationType.DelegateCall, - }) - }, [message]) + } + createTx(params).then(setSafeTx).catch(setSafeTxError) + }, [ + isTextMessage, + isTypedMessage, + message, + readOnlySignMessageLibContract, + setSafeTx, + setSafeTxError, + signMessageAddress, + ]) const handleSubmit = async () => { - setSubmitError(undefined) if (!safeTx || !onboard) return try { await dispatchSafeAppsTx(safeTx, requestId, onboard, safe.chainId) } catch (error) { - setSubmitError(error as Error) + setSafeTxError(asError(error)) } } return ( - - <> - - - - - - - {safeTx && ( - - - Data (hex encoded) - - {generateDataRowValue(safeTx.data.data, 'rawData')} - - )} - - - Signing method: {method} - + + - - Signing message: {readableMessage && } - - + + + - - - - Signing a message with your Safe Account requires a transaction on the blockchain + {safeTx && ( + + + Data (hex encoded) + {generateDataRowValue(safeTx.data.data, 'rawData')} - + )} + + + Signing method: {method} + + + + Signing message: {readableMessage && } + + + + + + + Signing a message with your Safe Account requires a transaction on the blockchain + + ) } -export default ReviewSafeAppsSignMessage +export default ReviewSignMessageOnChain diff --git a/src/components/tx-flow/flows/SignMessageOnChain/index.tsx b/src/components/tx-flow/flows/SignMessageOnChain/index.tsx new file mode 100644 index 0000000000..bcfc3c7672 --- /dev/null +++ b/src/components/tx-flow/flows/SignMessageOnChain/index.tsx @@ -0,0 +1,19 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import { AppTitle } from '@/components/tx-flow/flows/SignMessage' +import ReviewSignMessageOnChain, { + type SignMessageOnChainProps, +} from '@/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain' + +const SignMessageOnChainFlow = ({ props }: { props: SignMessageOnChainProps }) => { + return ( + } + step={0} + > + + + ) +} + +export default SignMessageOnChainFlow diff --git a/src/components/tx-flow/flows/SuccessScreen/StatusMessage.tsx b/src/components/tx-flow/flows/SuccessScreen/StatusMessage.tsx new file mode 100644 index 0000000000..572e5ee48a --- /dev/null +++ b/src/components/tx-flow/flows/SuccessScreen/StatusMessage.tsx @@ -0,0 +1,51 @@ +import classNames from 'classnames' +import { Box, Typography } from '@mui/material' +import LoadingSpinner, { SpinnerStatus } from '@/components/new-safe/create/steps/StatusStep/LoadingSpinner' +import { PendingStatus } from '@/store/pendingTxsSlice' +import css from './styles.module.css' + +const getStep = (status: PendingStatus, error?: Error) => { + switch (status) { + case PendingStatus.PROCESSING: + case PendingStatus.RELAYING: + return { + description: 'Transaction is now processing.', + instruction: 'The transaction was confirmed and is now being processed.', + } + case PendingStatus.INDEXING: + return { + description: 'Transaction was processed.', + instruction: 'It is now being indexed.', + } + default: + return { + description: error ? 'Transaction failed' : 'Transaction was successful.', + instruction: error ? error.message : '', + } + } +} + +const StatusMessage = ({ status, error }: { status: PendingStatus; error?: Error }) => { + const stepInfo = getStep(status, error) + + const isSuccess = status === undefined + const spinnerStatus = error ? SpinnerStatus.ERROR : isSuccess ? SpinnerStatus.SUCCESS : SpinnerStatus.PROCESSING + + return ( + <> + + + + {stepInfo.description} + + + {stepInfo.instruction && ( + + {stepInfo.instruction} + + )} + + ) +} + +export default StatusMessage diff --git a/src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx b/src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx new file mode 100644 index 0000000000..936fe3e6fe --- /dev/null +++ b/src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx @@ -0,0 +1,56 @@ +import { Box, Step, StepConnector, Stepper, Typography } from '@mui/material' +import css from '@/components/new-safe/create/steps/StatusStep/styles.module.css' +import EthHashInfo from '@/components/common/EthHashInfo' +import StatusStep from '@/components/new-safe/create/steps/StatusStep/StatusStep' +import useSafeInfo from '@/hooks/useSafeInfo' +import { PendingStatus } from '@/store/pendingTxsSlice' + +const StatusStepper = ({ status, txHash }: { status: PendingStatus; txHash?: string }) => { + const { safeAddress } = useSafeInfo() + + const isProcessing = status === PendingStatus.PROCESSING || status === PendingStatus.INDEXING || status === undefined + const isProcessed = status === PendingStatus.INDEXING || status === undefined + const isSuccess = status === undefined + + return ( + }> + + + + + Your transaction + + {txHash && ( + + )} + + + + + + + + {isProcessed ? 'Processed' : 'Processing'} + + + + + + + + {isSuccess ? 'Indexed' : 'Indexing'} + + + + + ) +} + +export default StatusStepper diff --git a/src/components/tx-flow/flows/SuccessScreen/index.tsx b/src/components/tx-flow/flows/SuccessScreen/index.tsx new file mode 100644 index 0000000000..f3d1fadf5a --- /dev/null +++ b/src/components/tx-flow/flows/SuccessScreen/index.tsx @@ -0,0 +1,83 @@ +import { useRouter } from 'next/router' +import StatusMessage from './StatusMessage' +import StatusStepper from './StatusStepper' +import { AppRoutes } from '@/config/routes' +import { Button, Container, Divider, Paper } from '@mui/material' +import classnames from 'classnames' +import Link from 'next/link' +import { type UrlObject } from 'url' +import css from './styles.module.css' +import { useAppSelector } from '@/store' +import { selectPendingTxById } from '@/store/pendingTxsSlice' +import { useEffect, useState } from 'react' +import { getBlockExplorerLink } from '@/utils/chains' +import { useCurrentChain } from '@/hooks/useChains' +import { TxEvent, txSubscribe } from '@/services/tx/txEvents' + +export const SuccessScreen = ({ txId }: { txId: string }) => { + const [localTxHash, setLocalTxHash] = useState() + const [error, setError] = useState() + const router = useRouter() + const chain = useCurrentChain() + const pendingTx = useAppSelector((state) => selectPendingTxById(state, txId)) + const { txHash = '', status } = pendingTx || {} + + useEffect(() => { + if (!txHash) return + + setLocalTxHash(txHash) + }, [txHash]) + + useEffect(() => { + const unsubscribe = txSubscribe(TxEvent.FAILED, (detail) => { + if (detail.txId === txId) setError(detail.error) + }) + + return unsubscribe + }, [txId]) + + const homeLink: UrlObject = { + pathname: AppRoutes.home, + query: { safe: router.query.safe }, + } + + const txLink = chain && localTxHash ? getBlockExplorerLink(chain, localTxHash) : undefined + + return ( + +
+ +
+ + {!error && ( + <> + +
+ +
+ + )} + + +
+ + + + {txLink && ( + + )} +
+
+ ) +} diff --git a/src/components/tx-flow/flows/SuccessScreen/styles.module.css b/src/components/tx-flow/flows/SuccessScreen/styles.module.css new file mode 100644 index 0000000000..aa72e2cf90 --- /dev/null +++ b/src/components/tx-flow/flows/SuccessScreen/styles.module.css @@ -0,0 +1,35 @@ +.row { + width: 100%; + padding: var(--space-4) var(--space-7); +} + +@media (max-width: 599.95px) { + .row { + padding: var(--space-2); + } +} + +.buttons { + display: flex; + justify-content: center; + gap: var(--space-2); + font-size: 14px; +} + +.instructions { + padding: var(--space-3); + margin-top: var(--space-4); + border-style: solid; + border-width: 1px; + border-radius: 6px; +} + +.errorBg { + background-color: var(--color-error-background); + border-color: var(--color-error-light); +} + +.infoBg { + background-color: var(--color-info-background); + border-color: var(--color-info-light); +} diff --git a/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx b/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx new file mode 100644 index 0000000000..44f2e7a844 --- /dev/null +++ b/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx @@ -0,0 +1,195 @@ +import { type ReactElement, useMemo, useState, useCallback, useContext, useEffect } from 'react' +import { type TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { useVisibleBalances } from '@/hooks/useVisibleBalances' +import useAddressBook from '@/hooks/useAddressBook' +import useChainId from '@/hooks/useChainId' +import { getSafeTokenAddress } from '@/components/common/SafeTokenWidget' +import useIsSafeTokenPaused from '@/hooks/useIsSafeTokenPaused' +import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' +import { useAppSelector } from '@/store' +import { selectSpendingLimits } from '@/store/spendingLimitsSlice' +import useWallet from '@/hooks/wallets/useWallet' +import { FormProvider, useForm } from 'react-hook-form' +import useSpendingLimit from '@/hooks/useSpendingLimit' +import { BigNumber } from '@ethersproject/bignumber' +import { sameAddress } from '@/utils/addresses' +import { Box, Button, CardActions, Divider, FormControl, Grid, SvgIcon, Typography } from '@mui/material' +import TokenIcon from '@/components/common/TokenIcon' +import AddressBookInput from '@/components/common/AddressBookInput' +import AddressInputReadOnly from '@/components/common/AddressInputReadOnly' +import InfoIcon from '@/public/images/notifications/info.svg' +import SpendingLimitRow from '@/components/tx/SpendingLimitRow' +import { TokenTransferFields, type TokenTransferParams, TokenTransferType } from '.' +import TxCard from '../../common/TxCard' +import { formatVisualAmount, safeFormatUnits } from '@/utils/formatters' +import commonCss from '@/components/tx-flow/common/styles.module.css' +import TokenAmountInput, { TokenAmountFields } from '@/components/common/TokenAmountInput' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' + +export const AutocompleteItem = (item: { tokenInfo: TokenInfo; balance: string }): ReactElement => ( + + + + + {item.tokenInfo.name} + + + {formatVisualAmount(item.balance, item.tokenInfo.decimals)} {item.tokenInfo.symbol} + + + +) + +const CreateTokenTransfer = ({ + params, + onSubmit, + txNonce, +}: { + params: TokenTransferParams + onSubmit: (data: TokenTransferParams) => void + txNonce?: number +}): ReactElement => { + const disableSpendingLimit = txNonce !== undefined + const { balances } = useVisibleBalances() + const addressBook = useAddressBook() + const chainId = useChainId() + const safeTokenAddress = getSafeTokenAddress(chainId) + const isSafeTokenPaused = useIsSafeTokenPaused() + const isOnlySpendingLimitBeneficiary = useIsOnlySpendingLimitBeneficiary() + const spendingLimits = useAppSelector(selectSpendingLimits) + const wallet = useWallet() + const { setNonce } = useContext(SafeTxContext) + const [recipientFocus, setRecipientFocus] = useState(!params.recipient) + + useEffect(() => { + if (txNonce) { + setNonce(txNonce) + } + }, [setNonce, txNonce]) + + const formMethods = useForm({ + defaultValues: { + ...params, + [TokenTransferFields.type]: disableSpendingLimit + ? TokenTransferType.multiSig + : isOnlySpendingLimitBeneficiary + ? TokenTransferType.spendingLimit + : params.type, + }, + mode: 'onChange', + delayError: 500, + }) + + const { + handleSubmit, + setValue, + watch, + formState: { errors }, + } = formMethods + + const recipient = watch(TokenTransferFields.recipient) + + // Selected token + const tokenAddress = watch(TokenAmountFields.tokenAddress) + const selectedToken = tokenAddress + ? balances.items.find((item) => item.tokenInfo.address === tokenAddress) + : undefined + + const type = watch(TokenTransferFields.type) + const spendingLimit = useSpendingLimit(selectedToken?.tokenInfo) + const isSpendingLimitType = type === TokenTransferType.spendingLimit + const spendingLimitAmount = spendingLimit ? BigNumber.from(spendingLimit.amount).sub(spendingLimit.spent) : undefined + const totalAmount = BigNumber.from(selectedToken?.balance || 0) + const maxAmount = isSpendingLimitType + ? spendingLimitAmount && totalAmount.gt(spendingLimitAmount) + ? spendingLimitAmount + : totalAmount + : totalAmount + + const balancesItems = useMemo(() => { + return isOnlySpendingLimitBeneficiary + ? balances.items.filter(({ tokenInfo }) => { + return spendingLimits?.some(({ beneficiary, token }) => { + return sameAddress(beneficiary, wallet?.address || '') && sameAddress(tokenInfo.address, token.address) + }) + }) + : balances.items + }, [balances.items, isOnlySpendingLimitBeneficiary, spendingLimits, wallet?.address]) + + const onMaxAmountClick = useCallback(() => { + if (!selectedToken) return + + const amount = + isSpendingLimitType && spendingLimitAmount && spendingLimitAmount.lte(selectedToken.balance) + ? spendingLimitAmount.toString() + : selectedToken.balance + + setValue(TokenAmountFields.amount, safeFormatUnits(amount, selectedToken.tokenInfo.decimals), { + shouldValidate: true, + }) + }, [isSpendingLimitType, selectedToken, setValue, spendingLimitAmount]) + + const isSafeTokenSelected = sameAddress(safeTokenAddress, tokenAddress) + const isDisabled = isSafeTokenSelected && isSafeTokenPaused + const isAddressValid = !!recipient && !errors[TokenTransferFields.recipient] + + return ( + + +
+ + {addressBook[recipient] ? ( + { + setValue(TokenTransferFields.recipient, '') + setRecipientFocus(true) + }} + > + + + ) : ( + + )} + + + + + {isDisabled && ( + + + + $SAFE is currently non-transferable. + + + )} + + {!disableSpendingLimit && !!spendingLimitAmount && ( + + + + )} + + + + + + + +
+
+ ) +} + +export default CreateTokenTransfer diff --git a/src/components/tx/modals/TokenTransferModal/ReviewSpendingLimitTx.tsx b/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx similarity index 72% rename from src/components/tx/modals/TokenTransferModal/ReviewSpendingLimitTx.tsx rename to src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx index 2f139ad1b6..5a4ba8633c 100644 --- a/src/components/tx/modals/TokenTransferModal/ReviewSpendingLimitTx.tsx +++ b/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx @@ -1,11 +1,10 @@ import type { ReactElement, SyntheticEvent } from 'react' -import { useMemo, useState } from 'react' +import { useContext, useMemo, useState } from 'react' import type { BigNumberish, BytesLike } from 'ethers' -import { Button, DialogContent, Typography } from '@mui/material' -import SendFromBlock from '@/components/tx/SendFromBlock' -import SendToBlock from '@/components/tx/SendToBlock' -import type { TokenTransferModalProps } from '.' -import { TokenTransferReview } from '@/components/tx/modals/TokenTransferModal/ReviewTokenTx' +import { Button, CardActions, Typography } from '@mui/material' +import SendToBlock from '@/components/tx-flow/flows/TokenTransfer/SendToBlock' +import { type TokenTransferParams } from '@/components/tx-flow/flows/TokenTransfer/index' +import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock' import useBalances from '@/hooks/useBalances' import useSpendingLimit from '@/hooks/useSpendingLimit' import useSpendingLimitGas from '@/hooks/useSpendingLimitGas' @@ -21,6 +20,9 @@ import { getTxOptions } from '@/utils/transactions' import { MODALS_EVENTS, trackEvent } from '@/services/analytics' import useOnboard from '@/hooks/wallets/useOnboard' import { WrongChainWarning } from '@/components/tx/WrongChainWarning' +import { asError } from '@/services/exceptions/utils' +import TxCard from '@/components/tx-flow/common/TxCard' +import { TxModalContext } from '@/components/tx-flow' export type SpendingLimitTxParams = { safeAddress: string @@ -33,9 +35,16 @@ export type SpendingLimitTxParams = { signature: BytesLike } -const ReviewSpendingLimitTx = ({ params, onSubmit }: TokenTransferModalProps): ReactElement => { +const ReviewSpendingLimitTx = ({ + params, + onSubmit, +}: { + params: TokenTransferParams + onSubmit: () => void +}): ReactElement => { const [isSubmittable, setIsSubmittable] = useState(true) const [submitError, setSubmitError] = useState() + const { setTxFlow } = useContext(TxModalContext) const currentChain = useCurrentChain() const onboard = useOnboard() const { safe, safeAddress } = useSafeInfo() @@ -66,10 +75,7 @@ const ReviewSpendingLimitTx = ({ params, onSubmit }: TokenTransferModalProps): R const { gasLimit, gasLimitLoading } = useSpendingLimitGas(txParams) - const [advancedParams, setManualParams] = useAdvancedParams({ - gasLimit, - nonce: params.txNonce, - }) + const [advancedParams, setManualParams] = useAdvancedParams(gasLimit) const handleSubmit = async (e: SyntheticEvent) => { e.preventDefault() @@ -84,12 +90,13 @@ const ReviewSpendingLimitTx = ({ params, onSubmit }: TokenTransferModalProps): R try { await dispatchSpendingLimitTxExecution(txParams, txOptions, onboard, safe.chainId, safeAddress) - onSubmit() - } catch (err) { - logError(Errors._801, (err as Error).message) + setTxFlow(undefined) + } catch (_err) { + const err = asError(_err) + logError(Errors._801, err) setIsSubmittable(true) - setSubmitError(err as Error) + setSubmitError(err) } } @@ -97,24 +104,18 @@ const ReviewSpendingLimitTx = ({ params, onSubmit }: TokenTransferModalProps): R return (
- - + + Spending limit transactions only appear in the interface once they are successfully processed and indexed. Pending transactions can only be viewed in your signer wallet application or under your wallet address on a Blockchain Explorer. - {token && } - + {token && } - + @@ -122,14 +123,16 @@ const ReviewSpendingLimitTx = ({ params, onSubmit }: TokenTransferModalProps): R Error submitting the transaction. Please try again. )} - + You're about to create a transaction and will need to confirm it with your currently connected wallet. - - + + + +
) } diff --git a/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx b/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx new file mode 100644 index 0000000000..2d8c3c33ce --- /dev/null +++ b/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx @@ -0,0 +1,50 @@ +import { useContext, useEffect } from 'react' +import useBalances from '@/hooks/useBalances' +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock' +import SendToBlock from '@/components/tx-flow/flows/TokenTransfer/SendToBlock' +import { createTokenTransferParams } from '@/services/tx/tokenTransferParams' +import { createTx } from '@/services/tx/tx-sender' +import type { TokenTransferParams } from '.' +import { SafeTxContext } from '../../SafeTxProvider' + +const ReviewTokenTransfer = ({ + params, + onSubmit, + txNonce, +}: { + params: TokenTransferParams + onSubmit: () => void + txNonce?: number +}) => { + const { setSafeTx, setSafeTxError, setNonce } = useContext(SafeTxContext) + const { balances } = useBalances() + const token = balances.items.find((item) => item.tokenInfo.address === params.tokenAddress) + + useEffect(() => { + if (txNonce !== undefined) { + setNonce(txNonce) + } + + if (!token) return + + const txParams = createTokenTransferParams( + params.recipient, + params.amount, + token.tokenInfo.decimals, + token.tokenInfo.address, + ) + + createTx(txParams, txNonce).then(setSafeTx).catch(setSafeTxError) + }, [params, txNonce, token, setNonce, setSafeTx, setSafeTxError]) + + return ( + + {token && } + + + + ) +} + +export default ReviewTokenTransfer diff --git a/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx b/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx new file mode 100644 index 0000000000..98e2535f31 --- /dev/null +++ b/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx @@ -0,0 +1,25 @@ +import { type ReactElement } from 'react' +import { type TokenTransferParams, TokenTransferType } from '@/components/tx-flow/flows/TokenTransfer/index' +import ReviewTokenTransfer from '@/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer' +import ReviewSpendingLimitTx from '@/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx' + +// TODO: Split this into separate flows +const ReviewTokenTx = ({ + params, + onSubmit, + txNonce, +}: { + params: TokenTransferParams + onSubmit: () => void + txNonce?: number +}): ReactElement => { + const isSpendingLimitTx = params.type === TokenTransferType.spendingLimit + + return isSpendingLimitTx ? ( + + ) : ( + + ) +} + +export default ReviewTokenTx diff --git a/src/components/tx-flow/flows/TokenTransfer/SendAmountBlock.tsx b/src/components/tx-flow/flows/TokenTransfer/SendAmountBlock.tsx new file mode 100644 index 0000000000..b5cd9a2301 --- /dev/null +++ b/src/components/tx-flow/flows/TokenTransfer/SendAmountBlock.tsx @@ -0,0 +1,57 @@ +import { type ReactNode } from 'react' +import { type TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { Grid, Typography } from '@mui/material' +import css from './styles.module.css' +import TokenIcon from '@/components/common/TokenIcon' +import { formatAmountPrecise } from '@/utils/formatNumber' +import { PSEUDO_APPROVAL_VALUES } from '@/components/tx/ApprovalEditor/utils/approvals' + +const AmountBlock = ({ + amount, + tokenInfo, + children, +}: { + amount: number | string + tokenInfo: Omit & { logoUri?: string } + children?: ReactNode +}) => { + return ( + + + {tokenInfo.symbol} + {children} + {amount === PSEUDO_APPROVAL_VALUES.UNLIMITED ? ( + {PSEUDO_APPROVAL_VALUES.UNLIMITED} + ) : ( + {formatAmountPrecise(amount, tokenInfo.decimals)} + )} + + ) +} + +const SendAmountBlock = ({ + amount, + tokenInfo, + children, + title = 'Send', +}: { + amount: number | string + tokenInfo: Omit & { logoUri?: string } + children?: ReactNode + title?: string +}) => { + return ( + + + + {title} + + + + {children} + + + ) +} + +export default SendAmountBlock diff --git a/src/components/tx-flow/flows/TokenTransfer/SendToBlock.tsx b/src/components/tx-flow/flows/TokenTransfer/SendToBlock.tsx new file mode 100644 index 0000000000..8ae39346cd --- /dev/null +++ b/src/components/tx-flow/flows/TokenTransfer/SendToBlock.tsx @@ -0,0 +1,21 @@ +import { Grid, Typography } from '@mui/material' +import EthHashInfo from '@/components/common/EthHashInfo' + +const SendToBlock = ({ address, title = 'To' }: { address: string; title?: string }) => { + return ( + + + + {title} + + + + + + + + + ) +} + +export default SendToBlock diff --git a/src/components/tx-flow/flows/TokenTransfer/index.tsx b/src/components/tx-flow/flows/TokenTransfer/index.tsx new file mode 100644 index 0000000000..e1fa557a0f --- /dev/null +++ b/src/components/tx-flow/flows/TokenTransfer/index.tsx @@ -0,0 +1,69 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import useTxStepper from '../../useTxStepper' +import CreateTokenTransfer from './CreateTokenTransfer' +import ReviewTokenTx from '@/components/tx-flow/flows/TokenTransfer/ReviewTokenTx' +import AssetsIcon from '@/public/images/sidebar/assets.svg' +import { ZERO_ADDRESS } from '@safe-global/safe-core-sdk/dist/src/utils/constants' +import { TokenAmountFields } from '@/components/common/TokenAmountInput' + +export enum TokenTransferType { + multiSig = 'multiSig', + spendingLimit = 'spendingLimit', +} + +enum Fields { + recipient = 'recipient', + type = 'type', +} + +export const TokenTransferFields = { ...Fields, ...TokenAmountFields } + +export type TokenTransferParams = { + [TokenTransferFields.recipient]: string + [TokenTransferFields.tokenAddress]: string + [TokenTransferFields.amount]: string + [TokenTransferFields.type]: TokenTransferType +} + +type TokenTransferFlowProps = Partial & { + txNonce?: number +} + +const defaultParams: TokenTransferParams = { + recipient: '', + tokenAddress: ZERO_ADDRESS, + amount: '', + type: TokenTransferType.multiSig, +} + +const TokenTransferFlow = ({ txNonce, ...params }: TokenTransferFlowProps) => { + const { data, step, nextStep, prevStep } = useTxStepper({ + ...defaultParams, + ...params, + }) + + const steps = [ + nextStep({ ...data, ...formData })} + />, + + null} />, + ] + + return ( + + {steps} + + ) +} + +export default TokenTransferFlow diff --git a/src/components/tx-flow/flows/TokenTransfer/styles.module.css b/src/components/tx-flow/flows/TokenTransfer/styles.module.css new file mode 100644 index 0000000000..3330447b22 --- /dev/null +++ b/src/components/tx-flow/flows/TokenTransfer/styles.module.css @@ -0,0 +1,5 @@ +.token { + display: flex; + align-items: center; + gap: var(--space-1); +} diff --git a/src/components/settings/ContractVersion/UpdateSafeDialog.tsx b/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx similarity index 54% rename from src/components/settings/ContractVersion/UpdateSafeDialog.tsx rename to src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx index b424aaf16b..62239c188f 100644 --- a/src/components/settings/ContractVersion/UpdateSafeDialog.tsx +++ b/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx @@ -1,62 +1,31 @@ -import { Button, Typography } from '@mui/material' -import { useState } from 'react' +import { useContext, useEffect } from 'react' +import { Typography } from '@mui/material' +import ExternalLink from '@/components/common/ExternalLink' import { LATEST_SAFE_VERSION } from '@/config/constants' - -import TxModal from '@/components/tx/TxModal' - -import useAsync from '@/hooks/useAsync' - -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import { createUpdateSafeTxs } from '@/services/tx/safeUpdateParams' - -import useSafeInfo from '@/hooks/useSafeInfo' import { useCurrentChain } from '@/hooks/useChains' -import ExternalLink from '@/components/common/ExternalLink' +import useSafeInfo from '@/hooks/useSafeInfo' +import { createUpdateSafeTxs } from '@/services/tx/safeUpdateParams' import { createMultiSendCallOnlyTx } from '@/services/tx/tx-sender' -import CheckWallet from '@/components/common/CheckWallet' - -const UpdateSafeSteps: TxStepperProps['steps'] = [ - { - label: 'Update Safe Account version', - render: (_, onSubmit) => , - }, -] - -const UpdateSafeDialog = () => { - const [open, setOpen] = useState(false) - - const handleClose = () => setOpen(false) - - return ( - <> - - {(isOk) => ( - - )} - - {open && } - - ) -} +import { SafeTxContext } from '../../SafeTxProvider' +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -const ReviewUpdateSafeStep = ({ onSubmit }: { onSubmit: () => void }) => { +export const UpdateSafeReview = () => { const { safe, safeLoaded } = useSafeInfo() const chain = useCurrentChain() + const { setSafeTx, setSafeTxError, setNonce } = useContext(SafeTxContext) - const [safeTx, safeTxError] = useAsync(() => { - if (!chain || !safeLoaded) return + useEffect(() => { + if (!chain || !safeLoaded) { + return + } const txs = createUpdateSafeTxs(safe, chain) - return createMultiSendCallOnlyTx(txs) - }, [chain, safe, safeLoaded]) + createMultiSendCallOnlyTx(txs).then(setSafeTx).catch(setSafeTxError) + }, [chain, safe, safeLoaded, setNonce, setSafeTx, setSafeTxError]) return ( - + null}> Update now to take advantage of new features and the highest security standards available. @@ -81,5 +50,3 @@ const ReviewUpdateSafeStep = ({ onSubmit }: { onSubmit: () => void }) => { ) } - -export default UpdateSafeDialog diff --git a/src/components/tx-flow/flows/UpdateSafe/index.tsx b/src/components/tx-flow/flows/UpdateSafe/index.tsx new file mode 100644 index 0000000000..082272d015 --- /dev/null +++ b/src/components/tx-flow/flows/UpdateSafe/index.tsx @@ -0,0 +1,13 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import { UpdateSafeReview } from './UpdateSafeReview' +import SettingsIcon from '@/public/images/sidebar/settings.svg' + +const UpdateSafeFlow = () => { + return ( + + + + ) +} + +export default UpdateSafeFlow diff --git a/src/components/tx-flow/index.tsx b/src/components/tx-flow/index.tsx new file mode 100644 index 0000000000..28e4bf8267 --- /dev/null +++ b/src/components/tx-flow/index.tsx @@ -0,0 +1,77 @@ +import { createContext, type ReactElement, type ReactNode, useState, useEffect, useCallback } from 'react' +import TxModalDialog from '@/components/common/TxModalDialog' +import { useRouter } from 'next/router' + +const noop = () => {} + +type TxModalContextType = { + txFlow: JSX.Element | undefined + setTxFlow: (txFlow: TxModalContextType['txFlow'], onClose?: () => void, shouldWarn?: boolean) => void + setFullWidth: (fullWidth: boolean) => void +} + +export const TxModalContext = createContext({ + txFlow: undefined, + setTxFlow: noop, + setFullWidth: noop, +}) + +export const TxModalProvider = ({ children }: { children: ReactNode }): ReactElement => { + const [txFlow, setFlow] = useState(undefined) + const [shouldWarn, setShouldWarn] = useState(true) + const [, setOnClose] = useState[1]>(noop) + const [fullWidth, setFullWidth] = useState(false) + const router = useRouter() + + const handleModalClose = useCallback(() => { + setOnClose((prevOnClose) => { + prevOnClose?.() + return noop + }) + setFlow(undefined) + }, [setFlow, setOnClose]) + + const handleShowWarning = useCallback(() => { + if (!shouldWarn) { + handleModalClose() + return + } + + const ok = confirm('Closing this window will discard your current progress.') + if (!ok) { + router.events.emit('routeChangeError') + throw 'routeChange aborted. This error can be safely ignored - https://github.com/zeit/next.js/issues/2476.' + } + + handleModalClose() + }, [shouldWarn, handleModalClose, router]) + + const setTxFlow = useCallback( + (txFlow: TxModalContextType['txFlow'], onClose?: () => void, shouldWarn?: boolean) => { + setFlow(txFlow) + setOnClose(() => onClose ?? noop) + setShouldWarn(shouldWarn ?? true) + }, + [setFlow, setOnClose], + ) + + // Show the confirmation dialog if user navigates + useEffect(() => { + if (!txFlow) return + + router.events.on('routeChangeStart', handleShowWarning) + return () => { + router.events.off('routeChangeStart', handleShowWarning) + } + }, [txFlow, handleShowWarning, router]) + + return ( + + {children} + + + {txFlow} + + + ) +} diff --git a/src/components/tx-flow/useTxStepper.tsx b/src/components/tx-flow/useTxStepper.tsx new file mode 100644 index 0000000000..0b6ac457ce --- /dev/null +++ b/src/components/tx-flow/useTxStepper.tsx @@ -0,0 +1,19 @@ +import { useCallback, useState } from 'react' + +const useTxStepper = (initialData: T) => { + const [step, setStep] = useState(0) + const [data, setData] = useState(initialData) + + const nextStep = useCallback((entireData: T) => { + setData(entireData) + setStep((prevStep) => prevStep + 1) + }, []) + + const prevStep = useCallback(() => { + setStep((prevStep) => prevStep - 1) + }, []) + + return { step, data, nextStep, prevStep } +} + +export default useTxStepper diff --git a/src/components/tx/AdvancedParams/AdvancedParamsForm.tsx b/src/components/tx/AdvancedParams/AdvancedParamsForm.tsx index 27de329299..c68716ff54 100644 --- a/src/components/tx/AdvancedParams/AdvancedParamsForm.tsx +++ b/src/components/tx/AdvancedParams/AdvancedParamsForm.tsx @@ -4,7 +4,6 @@ import { BigNumber } from 'ethers' import { FormProvider, useForm } from 'react-hook-form' import { safeFormatUnits, safeParseUnits } from '@/utils/formatters' import { FLOAT_REGEX } from '@/utils/validation' -import NonceForm from '../NonceForm' import ModalDialog from '@/components/common/ModalDialog' import { AdvancedField, type AdvancedParameters } from './types.d' import GasLimitInput from './GasLimitInput' @@ -15,33 +14,27 @@ import { HelpCenterArticle } from '@/config/constants' type AdvancedParamsFormProps = { params: AdvancedParameters onSubmit: (params: AdvancedParameters) => void - recommendedNonce?: number recommendedGasLimit?: AdvancedParameters['gasLimit'] isExecution: boolean isEIP1559: boolean - nonceReadonly?: boolean willRelay?: boolean } type FormData = { - [AdvancedField.nonce]: number [AdvancedField.userNonce]: number [AdvancedField.gasLimit]?: string [AdvancedField.maxFeePerGas]: string [AdvancedField.maxPriorityFeePerGas]: string - [AdvancedField.safeTxGas]: number } const AdvancedParamsForm = ({ params, ...props }: AdvancedParamsFormProps) => { const formMethods = useForm({ mode: 'onChange', defaultValues: { - nonce: params.nonce, userNonce: params.userNonce || 0, gasLimit: params.gasLimit?.toString() || undefined, maxFeePerGas: params.maxFeePerGas ? safeFormatUnits(params.maxFeePerGas) : '', maxPriorityFeePerGas: params.maxPriorityFeePerGas ? safeFormatUnits(params.maxPriorityFeePerGas) : '', - safeTxGas: params.safeTxGas, }, }) const { @@ -52,23 +45,19 @@ const AdvancedParamsForm = ({ params, ...props }: AdvancedParamsFormProps) => { const onBack = () => { props.onSubmit({ - nonce: params.nonce, userNonce: params.userNonce, gasLimit: params.gasLimit, maxFeePerGas: params.maxFeePerGas, maxPriorityFeePerGas: params.maxPriorityFeePerGas, - safeTxGas: params.safeTxGas, }) } const onSubmit = (data: FormData) => { props.onSubmit({ - nonce: data.nonce, userNonce: data.userNonce, gasLimit: data.gasLimit ? BigNumber.from(data.gasLimit) : undefined, maxFeePerGas: safeParseUnits(data.maxFeePerGas) || params.maxFeePerGas, maxPriorityFeePerGas: safeParseUnits(data.maxPriorityFeePerGas) || params.maxPriorityFeePerGas, - safeTxGas: data.safeTxGas || params.safeTxGas, }) } @@ -84,100 +73,59 @@ const AdvancedParamsForm = ({ params, ...props }: AdvancedParamsFormProps) => {
- {(params.nonce !== undefined || !!params.safeTxGas) && ( - - - Safe Account transaction - - - )} - - {/* Safe nonce */} - {params.nonce !== undefined && ( - - - - - - )} - - {/* safeTxGas (< v1.3.0) */} - {!!params.safeTxGas && ( + + + Execution parameters + + + + {/* User nonce */} + + + + + + + {/* Gas limit */} + + + + + {/* Gas price */} + {props.isEIP1559 && ( )} - {props.isExecution && ( - <> - - - Owner transaction (Execution) - - - - {/* User nonce */} - - - - - - - {/* Gas limit */} - - - - - {/* Gas price */} - {props.isEIP1559 && ( - - - - - - )} - - - - - - - - )} + + + + + {/* Help link */} diff --git a/src/components/tx/AdvancedParams/index.tsx b/src/components/tx/AdvancedParams/index.tsx index e76189eaaa..f50ef914da 100644 --- a/src/components/tx/AdvancedParams/index.tsx +++ b/src/components/tx/AdvancedParams/index.tsx @@ -8,10 +8,8 @@ import { type AdvancedParameters } from './types' type Props = { params: AdvancedParameters - recommendedNonce?: number recommendedGasLimit?: AdvancedParameters['gasLimit'] willExecute: boolean - nonceReadonly: boolean onFormSubmit: (data: AdvancedParameters) => void gasLimitError?: Error willRelay?: boolean @@ -19,10 +17,8 @@ type Props = { const AdvancedParams = ({ params, - recommendedNonce, recommendedGasLimit, willExecute, - nonceReadonly, onFormSubmit, gasLimitError, willRelay, @@ -44,9 +40,7 @@ const AdvancedParams = ({ void] => { +export const useAdvancedParams = ( + gasLimit?: AdvancedParameters['gasLimit'], +): [AdvancedParameters, (params: AdvancedParameters) => void] => { const [manualParams, setManualParams] = useState() const [gasPrice] = useGasPrice() const userNonce = useUserNonce() const advancedParams: AdvancedParameters = useMemo( () => ({ - nonce: manualParams?.nonce ?? nonce, userNonce: manualParams?.userNonce ?? userNonce, gasLimit: manualParams?.gasLimit ?? gasLimit, maxFeePerGas: manualParams?.maxFeePerGas ?? gasPrice?.maxFeePerGas, maxPriorityFeePerGas: manualParams?.maxPriorityFeePerGas ?? gasPrice?.maxPriorityFeePerGas, - safeTxGas: manualParams?.safeTxGas ?? safeTxGas, }), - [manualParams, nonce, userNonce, gasLimit, gasPrice?.maxFeePerGas, gasPrice?.maxPriorityFeePerGas, safeTxGas], + [manualParams, userNonce, gasLimit, gasPrice?.maxFeePerGas, gasPrice?.maxPriorityFeePerGas], ) return [advancedParams, setManualParams] diff --git a/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx b/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx index f83ab7502c..993deb5bba 100644 --- a/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx +++ b/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx @@ -1,441 +1,85 @@ -import { - fireEvent, - getAllByRole, - getByRole, - getByText, - mockWeb3Provider, - render, - type RenderResult, - waitFor, - act, -} from '@/tests/test-utils' +import { render } from '@/tests/test-utils' import ApprovalEditor from '.' -import { type SafeBalanceResponse, TokenType } from '@safe-global/safe-gateway-typescript-sdk' -import { hexlify, hexZeroPad, Interface } from 'ethers/lib/utils' -import { ERC20__factory, Multi_send_call_only__factory } from '@/types/contracts' -import type { BaseTransaction } from '@safe-global/safe-apps-sdk' -import { encodeMultiSendData } from '@safe-global/safe-core-sdk/dist/src/utils/transactions/utils' -import { parseUnits } from '@ethersproject/units' -import { getMultiSendCallOnlyContractAddress } from '@/services/contracts/safeContracts' -import { type SafeSignature, type SafeTransaction } from '@safe-global/safe-core-sdk-types' - -const PREFIX_TEXT = 'Approve access to' -const ERC20_INTERFACE = ERC20__factory.createInterface() - -const createApproveCallData = (spender: string, value: string) => { - return ERC20_INTERFACE.encodeFunctionData('approve', [spender, value]) -} - -const createNonApproveCallData = (to: string, value: string) => { - return ERC20_INTERFACE.encodeFunctionData('transfer', [to, value]) -} - -const getApprovalSummaryElement = (text: string, result: RenderResult): HTMLElement => { - const accordionSummary = result.getByText(PREFIX_TEXT, { exact: false }) - expect(accordionSummary.parentElement).not.toBeNull() - return accordionSummary.parentElement! -} - -const renderEditor = async (txs: BaseTransaction[], updateTxs?: (newTxs: BaseTransaction[]) => void) => { - if (txs.length === 0) { - // eslint-disable-next-line react/display-name - return () => - } - - let txData: string - let to: string - if (txs.length > 1) { - const multiSendCallData = encodeMultiSendData(txs.map((tx) => ({ ...tx, operation: 0 }))) - txData = Multi_send_call_only__factory.createInterface().encodeFunctionData('multiSend', [multiSendCallData]) - to = getMultiSendCallOnlyContractAddress('1') || '0x1' - } else { - txData = txs[0].data - to = txs[0].to - } - - const safeTx: SafeTransaction = { - data: { - to, - data: txData, - baseGas: 0, - gasPrice: 0, - gasToken: '0x0', - nonce: 1, - operation: txs.length > 1 ? 1 : 0, - refundReceiver: '0x0', - safeTxGas: 0, - value: '0x0', - }, - signatures: new Map(), - addSignature: function (signature: SafeSignature): void { - throw new Error('Function not implemented.') - }, - encodedSignatures: function (): string { - throw new Error('Function not implemented.') - }, - } - // eslint-disable-next-line react/display-name - return () => -} +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { OperationType } from '@safe-global/safe-core-sdk-types' +import * as approvalInfos from '@/components/tx/ApprovalEditor/hooks/useApprovalInfos' +import { createMockSafeTransaction } from '@/tests/transactions' describe('ApprovalEditor', () => { beforeEach(() => { jest.clearAllMocks() - localStorage.clear() }) - // Edit mode is used in the ReviewSafeAppsTxModal - // There we pass in an array of BaseTransactions. - describe('in edit mode', () => { - const updateCallback = jest.fn() - - describe('should render null', () => { - it('for empty txs', async () => { - const Editor = await renderEditor([], updateCallback) - const result = render() - expect(result.container).toBeEmptyDOMElement() - }) - - it('for a single tx containing an approve call with wrong params', async () => { - const testInterface = new Interface(['function approve(address, uint256, uint8)']) - const txs = [ - { - to: hexZeroPad('0x123', 20), - data: testInterface.encodeFunctionData('approve', [hexZeroPad('0x2', 20), '123', '1']), - value: '0', - }, - ] - const Editor = await renderEditor(txs, updateCallback) - const result = render() - expect(result.container).toBeEmptyDOMElement() - }) - - it('for multiple non approve txs', async () => { - const txs = [ - { - to: hexZeroPad('0x123', 20), - data: createNonApproveCallData(hexZeroPad('0x2', 20), '200'), - value: '0', - }, - { - to: hexZeroPad('0x123', 20), - data: createNonApproveCallData(hexZeroPad('0x3', 20), '12'), - value: '0', - }, - ] - const Editor = await renderEditor(txs, updateCallback) - - const result = render() - expect(result.container).toBeEmptyDOMElement() - }) - }) - - it('should render and edit multiple txs with partly missing token info', async () => { - const tokenAddress1 = hexZeroPad('0x123', 20) - const tokenAddress2 = hexZeroPad('0x234', 20) - - // tokenAddress2 gets its infos from the web3 provider - mockWeb3Provider([ - { - returnType: 'uint8', - returnValue: '12', - signature: 'decimals()', - }, - { - returnType: 'string', - returnValue: 'OTHER', - signature: 'symbol()', - }, - ]) - const mockBalances: SafeBalanceResponse = { - fiatTotal: '100', - items: [ - { - balance: '100', - fiatBalance: '100', - fiatConversion: '1', - tokenInfo: { - address: tokenAddress1, - decimals: 18, - logoUri: '', - name: 'Test', - symbol: 'TST', - type: TokenType.ERC20, - }, - }, - ], - } - const txs = [ - { - to: tokenAddress1, - data: createApproveCallData(hexZeroPad('0x2', 20), hexlify(parseUnits('100', 18))), - value: '0', - }, - { - to: tokenAddress1, - data: createNonApproveCallData(hexZeroPad('0x2', 20), hexlify(parseUnits('200', 18))), - value: '0', - }, - { - to: tokenAddress2, - data: createApproveCallData(hexZeroPad('0x2', 20), hexlify(parseUnits('300', 12))), - value: '0', - }, - ] - const Editor = await renderEditor(txs, updateCallback) - - const result = render(, { - initialReduxState: { - balances: { data: mockBalances, loading: false }, - }, - }) - await waitFor(() => { - const accordionSummary = getApprovalSummaryElement(PREFIX_TEXT, result) - getByText(accordionSummary, '2', { exact: false }) - getByText(accordionSummary, 'Tokens', { exact: false }) - }) - - // Edit first approval - { - const accordionSummary = getApprovalSummaryElement(PREFIX_TEXT, result) - const parentContainer = accordionSummary.closest('.MuiPaper-root') - const accordionDetails = parentContainer?.querySelector('.MuiAccordionDetails-root') - expect(accordionDetails).not.toBeNull() - - // toggle edit row - const buttons = getAllByRole(accordionDetails as HTMLElement, 'button') - // 2 rows with one button each - expect(buttons).toHaveLength(2) + it('returns null if there is no safe transaction', () => { + const result = render() - await waitFor(() => { - const amountInput = accordionDetails?.querySelector('input[name="approvals.0"]') as HTMLInputElement - expect(amountInput).not.toBeNull() - expect(amountInput).toHaveValue('100.0') - expect(amountInput).toBeEnabled() - }) - - const amountInput = accordionDetails?.querySelector('input[name="approvals.0"]') as HTMLInputElement - - await act(() => { - fireEvent.change(amountInput!, { target: { value: '123' } }) - }) - - await act(() => { - buttons[0].click() - }) - - await waitFor(() => { - expect(updateCallback).toHaveBeenCalledWith([ - { - to: tokenAddress1, - data: createApproveCallData(hexZeroPad('0x2', 20), hexlify(parseUnits('123', 18))), - value: '0', - }, - { - to: tokenAddress1, - data: createNonApproveCallData(hexZeroPad('0x2', 20), hexlify(parseUnits('200', 18))), - value: '0', - }, - { - to: tokenAddress2, - data: createApproveCallData(hexZeroPad('0x2', 20), hexlify(parseUnits('300', 12))), - value: '0', - }, - ]) - }) - } - - // Edit second approval - { - const accordionSummary = getApprovalSummaryElement(PREFIX_TEXT, result) - const parentContainer = accordionSummary.closest('.MuiPaper-root') - const accordionDetails = parentContainer?.querySelector('.MuiAccordionDetails-root') - expect(accordionDetails).not.toBeNull() - - // toggle edit row - const buttons = getAllByRole(accordionDetails as HTMLElement, 'button') - // 2 rows with one button each - expect(buttons).toHaveLength(2) - await waitFor(() => { - const amountInput = accordionDetails?.querySelector('input[name="approvals.1"]') as HTMLInputElement - expect(amountInput).not.toBeNull() - expect(amountInput).toHaveValue('300.0') - expect(amountInput).toBeEnabled() - }) - - const amountInput = accordionDetails?.querySelector('input[name="approvals.1"]') as HTMLInputElement - - await act(() => { - fireEvent.change(amountInput!, { target: { value: '456' } }) - }) - - await act(() => { - buttons[1].click() - }) - - await waitFor(() => { - expect(updateCallback).toHaveBeenCalledWith([ - { - to: tokenAddress1, - data: createApproveCallData(hexZeroPad('0x2', 20), hexlify(parseUnits('123', 18))), - value: '0', - }, - { - to: tokenAddress1, - data: createNonApproveCallData(hexZeroPad('0x2', 20), hexlify(parseUnits('200', 18))), - value: '0', - }, - { - to: tokenAddress2, - data: createApproveCallData(hexZeroPad('0x2', 20), hexlify(parseUnits('456', 12))), - value: '0', - }, - ]) - }) - } - }) - - it('should render and edit single tx', async () => { - const tokenAddress = hexZeroPad('0x123', 20) - const mockBalances: SafeBalanceResponse = { - fiatTotal: '100', - items: [ - { - balance: '100', - fiatBalance: '100', - fiatConversion: '1', - tokenInfo: { - address: tokenAddress, - decimals: 18, - logoUri: '', - name: 'Test', - symbol: 'TST', - type: TokenType.ERC20, - }, - }, - ], - } - const txs = [ - { - to: tokenAddress, - data: createApproveCallData(hexZeroPad('0x2', 20), hexlify(parseUnits('420', 18))), - value: '0', - }, - ] + expect(result.container).toBeEmptyDOMElement() + }) - const Editor = await renderEditor(txs, updateCallback) + it('returns null if there are no approvals', () => { + const mockSafeTx = createMockSafeTransaction({ to: '0x1', data: '0x', operation: OperationType.DelegateCall }) + jest.spyOn(approvalInfos, 'useApprovalInfos').mockReturnValue([[], undefined, false]) + const result = render() - const result = render(, { - initialReduxState: { - balances: { data: mockBalances, loading: false }, - }, - }) - await waitFor(() => { - const accordionSummary = getApprovalSummaryElement(PREFIX_TEXT, result) - getByText(accordionSummary, '420', { exact: false }) - getByText(accordionSummary, 'TST', { exact: false }) - }) + expect(result.container).toBeEmptyDOMElement() + }) - // Edit tx - const accordionSummary = getApprovalSummaryElement(PREFIX_TEXT, result) + it('renders an error', async () => { + jest + .spyOn(approvalInfos, 'useApprovalInfos') + .mockReturnValue([undefined, new Error('Error parsing approvals'), false]) + const mockSafeTx = createMockSafeTransaction({ to: '0x1', data: '0x', operation: OperationType.DelegateCall }) - const parentContainer = accordionSummary.closest('.MuiPaper-root') - const accordionDetails = parentContainer?.querySelector('.MuiAccordionDetails-root') - expect(accordionDetails).not.toBeNull() + const result = render() - // toggle edit row - await waitFor(() => { - const amountInput = accordionDetails?.querySelector('input[name="approvals.0"]') as HTMLInputElement - expect(amountInput).not.toBeNull() - expect(amountInput).toHaveValue('420.0') - expect(amountInput).toBeEnabled() - }) + expect(await result.queryByText('Error while decoding approval transactions.')).toBeInTheDocument() + }) - const amountInput = accordionDetails?.querySelector('input[name="approvals.0"]') as HTMLInputElement + it('renders a loading skeleton', async () => { + jest.spyOn(approvalInfos, 'useApprovalInfos').mockReturnValue([undefined, undefined, true]) + const mockSafeTx = createMockSafeTransaction({ to: '0x1', data: '0x', operation: OperationType.DelegateCall }) - await act(() => { - fireEvent.change(amountInput!, { target: { value: '100' } }) - }) + const result = render() - await act(() => { - getByRole(accordionDetails as HTMLElement, 'button').click() - }) + expect(await result.queryByTestId('approval-editor-loading')).toBeInTheDocument() + }) - await waitFor(() => { - expect(updateCallback).toHaveBeenCalledWith([ - { - to: tokenAddress, - data: createApproveCallData(hexZeroPad('0x2', 20), hexlify(parseUnits('100', 18))), - value: '0', - }, - ]) - }) - }) + it('renders a read-only view if there is no update callback', async () => { + const mockApprovalInfo = { + tokenInfo: { symbol: 'TST', decimals: 18, address: '0x3', type: TokenType.ERC20 }, + tokenAddress: '0x1', + spender: '0x2', + amount: '4200000', + amountFormatted: '420.0', + } + jest.spyOn(approvalInfos, 'useApprovalInfos').mockReturnValue([[mockApprovalInfo], undefined, false]) + const mockSafeTx = createMockSafeTransaction({ to: '0x1', data: '0x', operation: OperationType.DelegateCall }) + + const result = render() + + const amountInput = result.container.querySelector('input[name="approvals.0"]') as HTMLInputElement + + expect(amountInput).not.toBeInTheDocument() + expect(result.getByText('TST')) + expect(result.getByText('420')) + expect(result.getByText('0x2')) }) - // Readonly mode is used in the confirmationsModal - // It passes decodedTxData and txDetails instead of an array of base transactions and no update function - describe('in readonly mode', () => { - describe('should render null', () => { - it('for a single tx containing no approve call', async () => { - const txs: BaseTransaction[] = [ - { - to: hexZeroPad('0x123', 20), - data: createNonApproveCallData(hexZeroPad('0x2', 20), '20'), - value: '420', - }, - ] - const Editor = await renderEditor(txs) - const result = render() - expect(result.container).toBeEmptyDOMElement() - }) + it('renders a form if there is an update callback', async () => { + const mockApprovalInfo = { + tokenInfo: { symbol: 'TST', decimals: 18, address: '0x3', type: TokenType.ERC20 }, + tokenAddress: '0x1', + spender: '0x2', + amount: '4200000', + amountFormatted: '420.0', + } + jest.spyOn(approvalInfos, 'useApprovalInfos').mockReturnValue([[mockApprovalInfo], undefined, false]) + const mockSafeTx = createMockSafeTransaction({ to: '0x1', data: '0x', operation: OperationType.DelegateCall }) - describe('should render approval(s)', () => { - it('for single approval tx of token in balances', async () => { - const tokenAddress = hexZeroPad('0x123', 20) - const mockBalance: SafeBalanceResponse = { - fiatTotal: '100', - items: [ - { - balance: '100', - fiatBalance: '100', - fiatConversion: '1', - tokenInfo: { - address: tokenAddress, - decimals: 18, - logoUri: '', - name: 'Test', - symbol: 'TST', - type: TokenType.ERC20, - }, - }, - ], - } - const txs: BaseTransaction[] = [ - { - to: tokenAddress, - data: createApproveCallData(hexZeroPad('0x2', 20), hexlify(parseUnits('420', 18))), - value: '0', - }, - ] + const result = render() - const Editor = await renderEditor(txs) + const amountInput = result.container.querySelector('input[name="approvals.0"]') as HTMLInputElement - const result = render(, { - initialReduxState: { - balances: { - loading: false, - data: mockBalance, - }, - }, - }) - await waitFor(() => { - const accordionSummary = getApprovalSummaryElement(PREFIX_TEXT, result) - getByText(accordionSummary, '420', { exact: false }) - getByText(accordionSummary, 'TST', { exact: false }) - }) - }) - }) - }) + expect(amountInput).toBeInTheDocument() }) }) diff --git a/src/components/tx/ApprovalEditor/ApprovalEditorForm.test.tsx b/src/components/tx/ApprovalEditor/ApprovalEditorForm.test.tsx new file mode 100644 index 0000000000..2b7650baa7 --- /dev/null +++ b/src/components/tx/ApprovalEditor/ApprovalEditorForm.test.tsx @@ -0,0 +1,110 @@ +import { fireEvent, getAllByRole, render, waitFor } from '@/tests/test-utils' +import { hexZeroPad } from 'ethers/lib/utils' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { ApprovalEditorForm } from '@/components/tx/ApprovalEditor/ApprovalEditorForm' +import { getAllByTestId } from '@testing-library/dom' + +describe('ApprovalEditorForm', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const updateCallback = jest.fn() + + it('should render and edit multiple txs', async () => { + const tokenAddress1 = hexZeroPad('0x123', 20) + const tokenAddress2 = hexZeroPad('0x234', 20) + + const mockApprovalInfos = [ + { + tokenInfo: { symbol: 'TST', decimals: 18, address: tokenAddress1, type: TokenType.ERC20 }, + tokenAddress: '0x1', + spender: '0x2', + amount: '4200000', + amountFormatted: '420.0', + }, + { + tokenInfo: { symbol: 'TST', decimals: 18, address: tokenAddress2, type: TokenType.ERC20 }, + tokenAddress: '0x1', + spender: '0x2', + amount: '6900000', + amountFormatted: '69.0', + }, + ] + + const result = render() + + // All approvals are rendered + const approvalItems = getAllByTestId(result.container, 'approval-item') + expect(approvalItems).toHaveLength(2) + + // One button for each approval + const buttons = getAllByRole(result.container, 'button') + expect(buttons).toHaveLength(2) + + // First approval value is rendered + await waitFor(() => { + const amountInput = result.container.querySelector('input[name="approvals.0"]') as HTMLInputElement + expect(amountInput).not.toBeNull() + expect(amountInput).toHaveValue('420.0') + expect(amountInput).toBeEnabled() + }) + + // Change value of first approval + const amountInput1 = result.container.querySelector('input[name="approvals.0"]') as HTMLInputElement + fireEvent.change(amountInput1!, { target: { value: '123' } }) + fireEvent.click(buttons[0]) + + expect(updateCallback).toHaveBeenCalledWith(['123', '69.0']) + + // Second approval value is rendered + await waitFor(() => { + const amountInput = result.container.querySelector('input[name="approvals.1"]') as HTMLInputElement + expect(amountInput).not.toBeNull() + expect(amountInput).toHaveValue('69.0') + expect(amountInput).toBeEnabled() + }) + + // Change value of second approval + const amountInput2 = result.container.querySelector('input[name="approvals.1"]') as HTMLInputElement + fireEvent.change(amountInput2!, { target: { value: '456' } }) + fireEvent.click(buttons[1]) + + expect(updateCallback).toHaveBeenCalledWith(['123', '456']) + }) + + it('should render and edit single tx', async () => { + const tokenAddress = hexZeroPad('0x123', 20) + + const mockApprovalInfo = { + tokenInfo: { symbol: 'TST', decimals: 18, address: tokenAddress, type: TokenType.ERC20 }, + tokenAddress: '0x1', + spender: '0x2', + amount: '4200000', + amountFormatted: '420.0', + } + + const result = render() + + // Approval item is rendered + const approvalItem = result.getByTestId('approval-item') + expect(approvalItem).not.toBeNull() + + // Input with correct value is rendered + await waitFor(() => { + const amountInput = result.container.querySelector('input[name="approvals.0"]') as HTMLInputElement + expect(amountInput).not.toBeNull() + expect(amountInput).toHaveValue('420.0') + expect(amountInput).toBeEnabled() + }) + + // Change value and save + const amountInput = result.container.querySelector('input[name="approvals.0"]') as HTMLInputElement + const saveButton = result.getByRole('button') + + fireEvent.change(amountInput!, { target: { value: '100' } }) + fireEvent.click(saveButton) + + expect(updateCallback).toHaveBeenCalledWith(['100']) + }) +}) diff --git a/src/components/tx/ApprovalEditor/ApprovalEditorForm.tsx b/src/components/tx/ApprovalEditor/ApprovalEditorForm.tsx index efd0a9729a..4ee3244852 100644 --- a/src/components/tx/ApprovalEditor/ApprovalEditorForm.tsx +++ b/src/components/tx/ApprovalEditor/ApprovalEditorForm.tsx @@ -1,5 +1,4 @@ -import PrefixedEthHashInfo from '@/components/common/EthHashInfo' -import { Grid, Typography, IconButton, SvgIcon, Divider, List, ListItem } from '@mui/material' +import { IconButton, SvgIcon, List, ListItem } from '@mui/material' import { FormProvider, useForm } from 'react-hook-form' import css from './styles.module.css' import CheckIcon from '@mui/icons-material/Check' @@ -8,6 +7,7 @@ import { ApprovalValueField } from './ApprovalValueField' import { MODALS_EVENTS } from '@/services/analytics' import Track from '@/components/common/Track' import { useMemo } from 'react' +import ApprovalItem from '@/components/tx/ApprovalEditor/ApprovalItem' export type ApprovalEditorFormData = { approvals: string[] @@ -18,9 +18,8 @@ export const ApprovalEditorForm = ({ updateApprovals, }: { approvalInfos: ApprovalInfo[] - updateApprovals?: (newApprovals: string[]) => void + updateApprovals: (newApprovals: string[]) => void }) => { - const isReadonly = updateApprovals === undefined const initialApprovals = useMemo(() => approvalInfos.map((info) => info.amountFormatted), [approvalInfos]) const formMethods = useForm({ @@ -37,53 +36,34 @@ export const ApprovalEditorForm = ({ } = formMethods const onSave = () => { - if (isReadonly) return const formData = getValues('approvals') updateApprovals(formData) reset({ approvals: formData }) } return ( - - + + {approvalInfos.map((tx, idx) => ( -
- {idx > 0 && } - - - - {/* Input */} - - - {/* Save button */} - {!isReadonly && ( - - - - - - )} - - - - - Spender - - - - {' '} - - - - -
+ + + <> + + + + + + + + + ))} -
-
+ + ) } diff --git a/src/components/tx/ApprovalEditor/ApprovalItem.tsx b/src/components/tx/ApprovalEditor/ApprovalItem.tsx new file mode 100644 index 0000000000..8a75c02623 --- /dev/null +++ b/src/components/tx/ApprovalEditor/ApprovalItem.tsx @@ -0,0 +1,32 @@ +import { type ReactElement } from 'react' +import { Alert, Grid, Typography } from '@mui/material' +import css from '@/components/tx/ApprovalEditor/styles.module.css' +import PrefixedEthHashInfo from '@/components/common/EthHashInfo' + +const ApprovalItem = ({ spender, children }: { spender: string; children: ReactElement }) => { + return ( + + + + {children} + + + + + + Spender + + + + + + + + + + + + ) +} + +export default ApprovalItem diff --git a/src/components/tx/ApprovalEditor/ApprovalValueField.tsx b/src/components/tx/ApprovalEditor/ApprovalValueField.tsx index bd72160496..908454565c 100644 --- a/src/components/tx/ApprovalEditor/ApprovalValueField.tsx +++ b/src/components/tx/ApprovalEditor/ApprovalValueField.tsx @@ -15,15 +15,7 @@ const ApprovalOption = ({ menuItemProps, value }: { menuItemProps: MenuItemProps ) } -export const ApprovalValueField = ({ - name, - readonly = false, - tx, -}: { - name: string - readonly?: boolean - tx: ApprovalInfo -}) => { +export const ApprovalValueField = ({ name, tx }: { name: string; tx: ApprovalInfo }) => { const { control } = useFormContext() const selectValues = Object.values(PSEUDO_APPROVAL_VALUES) @@ -59,8 +51,6 @@ export const ApprovalValueField = ({ onInputChange={(_, value) => { onChange(value) }} - readOnly={readonly} - disabled={readonly} disableClearable selectOnFocus componentsProps={{ @@ -87,7 +77,6 @@ export const ApprovalValueField = ({ paddingLeft: 1, flexWrap: 'nowrap !important', }, - readOnly: readonly, startAdornment: ( diff --git a/src/components/tx/ApprovalEditor/Approvals.tsx b/src/components/tx/ApprovalEditor/Approvals.tsx new file mode 100644 index 0000000000..514b7da3b8 --- /dev/null +++ b/src/components/tx/ApprovalEditor/Approvals.tsx @@ -0,0 +1,28 @@ +import { Grid, List, ListItem } from '@mui/material' + +import { type ApprovalInfo } from '@/components/tx/ApprovalEditor/hooks/useApprovalInfos' +import css from './styles.module.css' +import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock' +import ApprovalItem from '@/components/tx/ApprovalEditor/ApprovalItem' + +const Approvals = ({ approvalInfos }: { approvalInfos: ApprovalInfo[] }) => { + return ( + + {approvalInfos.map((tx) => { + if (!tx.tokenInfo) return <> + + return ( + + + + + + + + ) + })} + + ) +} + +export default Approvals diff --git a/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.test.ts b/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.test.ts new file mode 100644 index 0000000000..4811eeeb1c --- /dev/null +++ b/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.test.ts @@ -0,0 +1,147 @@ +import { renderHook } from '@/tests/test-utils' +import { hexZeroPad, Interface } from 'ethers/lib/utils' +import { useApprovalInfos } from '@/components/tx/ApprovalEditor/hooks/useApprovalInfos' +import { waitFor } from '@testing-library/react' +import { createMockSafeTransaction } from '@/tests/transactions' +import { OperationType } from '@safe-global/safe-core-sdk-types' +import { ERC20__factory } from '@/types/contracts' +import { type ApprovalInfo } from '@/components/tx/ApprovalEditor/utils/approvals' +import * as balances from '@/hooks/useBalances' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { BigNumber } from '@ethersproject/bignumber' +import * as getTokenInfo from '@/utils/tokens' + +const ERC20_INTERFACE = ERC20__factory.createInterface() + +const createNonApproveCallData = (to: string, value: string) => { + return ERC20_INTERFACE.encodeFunctionData('transfer', [to, value]) +} + +describe('useApprovalInfos', () => { + beforeEach(() => { + jest.restoreAllMocks() + }) + + it('returns an empty array if no Safe Transaction exists', async () => { + const { result } = renderHook(() => useApprovalInfos(undefined)) + + expect(result.current).toStrictEqual([[], undefined, true]) + + await waitFor(() => { + expect(result.current).toStrictEqual([[], undefined, false]) + }) + }) + + it('returns an empty array if the transaction does not contain any approvals', async () => { + const mockSafeTx = createMockSafeTransaction({ + to: hexZeroPad('0x123', 20), + data: createNonApproveCallData(hexZeroPad('0x2', 20), '20'), + operation: OperationType.DelegateCall, + }) + + const { result } = renderHook(() => useApprovalInfos(mockSafeTx)) + + await waitFor(() => { + expect(result.current).toStrictEqual([[], undefined, false]) + }) + }) + + it('returns an ApprovalInfo if the transaction contains an approval', async () => { + const testInterface = new Interface(['function approve(address, uint256)']) + + const mockSafeTx = createMockSafeTransaction({ + to: hexZeroPad('0x123', 20), + data: testInterface.encodeFunctionData('approve', [hexZeroPad('0x2', 20), '123']), + operation: OperationType.DelegateCall, + }) + + const { result } = renderHook(() => useApprovalInfos(mockSafeTx)) + + const mockApproval: ApprovalInfo = { + amount: BigNumber.from('123'), + amountFormatted: '0.000000000000000123', + spender: '0x0000000000000000000000000000000000000002', + tokenAddress: '0x0000000000000000000000000000000000000123', + tokenInfo: undefined, + } + + await waitFor(() => { + expect(result.current).toEqual([[mockApproval], undefined, false]) + }) + }) + + it('returns an ApprovalInfo with token infos if the token exists in balances', async () => { + const mockBalanceItem = { + balance: '40', + fiatBalance: '40', + fiatConversion: '1', + tokenInfo: { + address: hexZeroPad('0x123', 20), + decimals: 18, + logoUri: '', + name: 'Hidden Token', + symbol: 'HT', + type: TokenType.ERC20, + }, + } + + jest + .spyOn(balances, 'default') + .mockReturnValue({ balances: { fiatTotal: '0', items: [mockBalanceItem] }, error: undefined, loading: false }) + const testInterface = new Interface(['function approve(address, uint256)']) + + const mockSafeTx = createMockSafeTransaction({ + to: hexZeroPad('0x123', 20), + data: testInterface.encodeFunctionData('approve', [hexZeroPad('0x2', 20), '123']), + operation: OperationType.DelegateCall, + }) + + const { result } = renderHook(() => useApprovalInfos(mockSafeTx)) + + const mockApproval: ApprovalInfo = { + amount: BigNumber.from('123'), + amountFormatted: '0.000000000000000123', + spender: '0x0000000000000000000000000000000000000002', + tokenAddress: '0x0000000000000000000000000000000000000123', + tokenInfo: mockBalanceItem.tokenInfo, + } + + await waitFor(() => { + expect(result.current).toEqual([[mockApproval], undefined, false]) + }) + }) + + it('fetches token info for an approval if its missing', async () => { + const mockTokenInfo = { + address: '0x0000000000000000000000000000000000000123', + symbol: 'HT', + decimals: 18, + type: TokenType.ERC20, + } + const fetchMock = jest + .spyOn(getTokenInfo, 'getERC20TokenInfoOnChain') + .mockReturnValue(Promise.resolve(mockTokenInfo)) + const testInterface = new Interface(['function approve(address, uint256)']) + + const mockSafeTx = createMockSafeTransaction({ + to: hexZeroPad('0x123', 20), + data: testInterface.encodeFunctionData('approve', [hexZeroPad('0x2', 20), '123']), + operation: OperationType.DelegateCall, + }) + + const { result } = renderHook(() => useApprovalInfos(mockSafeTx)) + + const mockApproval: ApprovalInfo = { + amount: BigNumber.from('123'), + amountFormatted: '0.000000000000000123', + spender: '0x0000000000000000000000000000000000000002', + tokenAddress: '0x0000000000000000000000000000000000000123', + tokenInfo: mockTokenInfo, + } + + await waitFor(() => { + expect(result.current).toEqual([[mockApproval], undefined, false]) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.ts b/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.ts index 0d60122677..664c2220ea 100644 --- a/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.ts +++ b/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.ts @@ -1,12 +1,12 @@ import useAsync from '@/hooks/useAsync' import useBalances from '@/hooks/useBalances' -import { ApprovalModule, type ApprovalModuleResponse } from '@/services/security/modules/ApprovalModule' -import type { SecurityResponse } from '@/services/security/modules/types' +import { ApprovalModule } from '@/services/security/modules/ApprovalModule' import { getERC20TokenInfoOnChain, UNLIMITED_APPROVAL_AMOUNT } from '@/utils/tokens' import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' import { type TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' import { ethers } from 'ethers' import { PSEUDO_APPROVAL_VALUES } from '../utils/approvals' +import { useMemo } from 'react' export type ApprovalInfo = { tokenInfo: (Omit & { logoUri?: string }) | undefined @@ -18,30 +18,28 @@ export type ApprovalInfo = { const ApprovalModuleInstance = new ApprovalModule() -const useApprovalData = (safeTransaction: SafeTransaction | undefined) => { - return useAsync>(() => { - if (!safeTransaction) { - return - } +export const useApprovalInfos = ( + safeTransaction: SafeTransaction | undefined, +): [ApprovalInfo[] | undefined, Error | undefined, boolean] => { + const { balances } = useBalances() + const approvals = useMemo(() => { + if (!safeTransaction) return return ApprovalModuleInstance.scanTransaction({ safeTransaction }) }, [safeTransaction]) -} - -export const useApprovalInfos = (safeTransaction: SafeTransaction | undefined) => { - const [approvals] = useApprovalData(safeTransaction) - const { balances } = useBalances() + const hasApprovalSignatures = !!approvals && !!approvals.payload && approvals.payload.length > 0 - return useAsync( + const [approvalInfos, error, loading] = useAsync( async () => { - if (!approvals || !approvals.payload || approvals.payload.length === 0) return Promise.resolve([]) + if (!hasApprovalSignatures) return return Promise.all( approvals.payload.map(async (approval) => { let tokenInfo: Omit | undefined = balances.items.find( (item) => item.tokenInfo.address === approval.tokenAddress, )?.tokenInfo + if (!tokenInfo) { tokenInfo = await getERC20TokenInfoOnChain(approval.tokenAddress) } @@ -54,7 +52,9 @@ export const useApprovalInfos = (safeTransaction: SafeTransaction | undefined) = }), ) }, - [balances.items.length, approvals], + [hasApprovalSignatures, balances.items.length], false, // Do not clear data on balance updates ) + + return [hasApprovalSignatures ? approvalInfos : [], error, loading] } diff --git a/src/components/tx/ApprovalEditor/index.tsx b/src/components/tx/ApprovalEditor/index.tsx index d7271092f6..0938b743ae 100644 --- a/src/components/tx/ApprovalEditor/index.tsx +++ b/src/components/tx/ApprovalEditor/index.tsx @@ -1,50 +1,27 @@ -import TokenIcon from '@/components/common/TokenIcon' -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' - -import { Accordion, AccordionDetails, AccordionSummary, Box, IconButton, Skeleton, Typography } from '@mui/material' -import { groupBy } from 'lodash' +import { Alert, Box, Divider, Skeleton, SvgIcon, Typography } from '@mui/material' +import { type MetaTransactionData, type SafeTransaction } from '@safe-global/safe-core-sdk-types' import css from './styles.module.css' -import { UNLIMITED_APPROVAL_AMOUNT } from '@/utils/tokens' import { ApprovalEditorForm } from './ApprovalEditorForm' -import { type ReactNode } from 'react' -import { type ApprovalInfo, updateApprovalTxs } from './utils/approvals' +import { updateApprovalTxs } from './utils/approvals' import { useApprovalInfos } from './hooks/useApprovalInfos' import { decodeSafeTxToBaseTransactions } from '@/utils/transactions' -import { type MetaTransactionData, type SafeTransaction } from '@safe-global/safe-core-sdk-types' - -const SummaryWrapper = ({ children }: { children: ReactNode | ReactNode[] }) => { - return ( - - - Approve access to - - - {children} - - - ) -} - -const Summary = ({ approvalInfos }: { approvalInfos: ApprovalInfo[] }) => { - const uniqueTokens = groupBy(approvalInfos, (approvalInfo) => approvalInfo.tokenAddress) - const uniqueTokenCount = Object.keys(uniqueTokens).length - - if (approvalInfos.length === 1) { - const approval = approvalInfos[0] - const amount = UNLIMITED_APPROVAL_AMOUNT.eq(approval.amount) ? 'unlimited' : approval.amountFormatted - return ( - - {amount} - - {approval.tokenInfo?.symbol} - - ) - } +import EditIcon from '@/public/images/common/edit.svg' +import commonCss from '@/components/tx-flow/common/styles.module.css' +import Approvals from '@/components/tx/ApprovalEditor/Approvals' +const Title = () => { return ( - - {uniqueTokenCount} Token{uniqueTokenCount > 1 ? 's' : ''} - +
+
+ +
+
+ Approve access to + + This allows contracts to spend the selected amounts of your asset balance. + +
+
) } @@ -57,50 +34,38 @@ export const ApprovalEditor = ({ }) => { const [readableApprovals, error, loading] = useApprovalInfos(safeTransaction) - if (!readableApprovals || readableApprovals.length === 0 || !safeTransaction) { + if (readableApprovals?.length === 0 || !safeTransaction) { return null } - const extractedTxs = decodeSafeTxToBaseTransactions(safeTransaction) + const updateApprovals = (approvals: string[]) => { + if (!updateTransaction) return + + const extractedTxs = decodeSafeTxToBaseTransactions(safeTransaction) - // If a callback is handed in, we update the txs on change, otherwise a `undefined` callback will change the form to readonly - const updateApprovals = - updateTransaction === undefined - ? undefined - : (approvals: string[]) => { - const updatedTxs = updateApprovalTxs(approvals, readableApprovals, extractedTxs) - updateTransaction(updatedTxs) - } + const updatedTxs = updateApprovalTxs(approvals, readableApprovals, extractedTxs) + updateTransaction(updatedTxs) + } + + const isReadOnly = updateTransaction === undefined return ( - - - - - } - > - {' '} - {error ? ( - Error while decoding approval transactions. - ) : loading || !readableApprovals ? ( - - ) : ( - - )} - - - {loading || !readableApprovals ? null : ( - <> - - This allows contracts to spend the selected amounts of your asset balance. - - - - )} - - + + + {error ? ( + <Alert severity="error">Error while decoding approval transactions.</Alert> + ) : loading || !readableApprovals ? ( + <Skeleton variant="rounded" height={100} data-testid="approval-editor-loading" /> + ) : isReadOnly ? ( + <Approvals approvalInfos={readableApprovals} /> + ) : ( + <ApprovalEditorForm approvalInfos={readableApprovals} updateApprovals={updateApprovals} /> + )} + + <Box mt={2}> + <Divider className={commonCss.nestedDivider} /> + </Box> + </Box> ) } diff --git a/src/components/tx/ApprovalEditor/styles.module.css b/src/components/tx/ApprovalEditor/styles.module.css index de67bad129..68a1a85c84 100644 --- a/src/components/tx/ApprovalEditor/styles.module.css +++ b/src/components/tx/ApprovalEditor/styles.module.css @@ -11,8 +11,12 @@ background-color: var(--color-warning-background); } -.approval { - padding: var(--space-1); +.alert { + width: 100%; +} + +.alert :global .MuiAlert-message { + width: 100%; } .approvalAmount { @@ -23,8 +27,8 @@ .iconButton { border-radius: 4px; padding: 6px; - width: 48px; - height: 48px; + width: 52px; + height: 52px; background-color: var(--color-primary-main); } @@ -45,5 +49,29 @@ } .approvalsList { - padding-bottom: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.wrapper { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.icon { + width: 34px; + height: 34px; + border-radius: 6px; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + background-color: var(--color-warning-background); +} + +.icon svg { + color: var(--color-warning-main); } diff --git a/src/components/tx/DecodedTx/index.test.tsx b/src/components/tx/DecodedTx/index.test.tsx index 9637f14617..983f8c4b73 100644 --- a/src/components/tx/DecodedTx/index.test.tsx +++ b/src/components/tx/DecodedTx/index.test.tsx @@ -29,7 +29,7 @@ describe('DecodedTx', () => { fireEvent.click(result.getByText('Transaction details')) - expect(result.queryByText('Native token transfer')).toBeInTheDocument() + expect(result.queryAllByText('Native token transfer').length).toBe(2) expect(result.queryByText('to(address):')).toBeInTheDocument() expect(result.queryByText('0x3430...7600')).toBeInTheDocument() expect(result.queryByText('value(uint256):')).toBeInTheDocument() @@ -79,7 +79,7 @@ describe('DecodedTx', () => { fireEvent.click(result.getByText('Transaction details')) await waitFor(() => { - expect(result.queryByText('transfer')).toBeInTheDocument() + expect(result.queryAllByText('transfer').length).toBe(2) expect(result.queryByText('to(address):')).toBeInTheDocument() expect(result.queryByText('0x474e...78C8')).toBeInTheDocument() expect(result.queryByText('value(uint256):')).toBeInTheDocument() @@ -179,8 +179,8 @@ describe('DecodedTx', () => { await waitFor(() => { expect(result.queryByText('multi Send')).toBeInTheDocument() expect(result.queryByText('transactions(bytes):')).toBeInTheDocument() - expect(result.queryByText('Action 1')).toBeInTheDocument() - expect(result.queryByText('Action 2')).toBeInTheDocument() + expect(result.queryByText('1')).toBeInTheDocument() + expect(result.queryByText('2')).toBeInTheDocument() }) }) @@ -216,6 +216,6 @@ describe('DecodedTx', () => { fireEvent.click(result.getByText('Transaction details')) - expect(await result.findByText('deposit')).toBeInTheDocument() + expect((await result.findAllByText('deposit')).length).toBe(2) }) }) diff --git a/src/components/tx/DecodedTx/index.tsx b/src/components/tx/DecodedTx/index.tsx index 44294248ee..8939a26f53 100644 --- a/src/components/tx/DecodedTx/index.tsx +++ b/src/components/tx/DecodedTx/index.tsx @@ -1,5 +1,14 @@ -import { type SyntheticEvent, type ReactElement, useMemo } from 'react' -import { Accordion, AccordionDetails, AccordionSummary, Box, Skeleton, Typography } from '@mui/material' +import { type SyntheticEvent, type ReactElement, useMemo, memo } from 'react' +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Skeleton, + SvgIcon, + Tooltip, + Typography, +} from '@mui/material' import { OperationType, type SafeTransaction } from '@safe-global/safe-core-sdk-types' import { type DecodedDataResponse, @@ -12,13 +21,18 @@ import useChainId from '@/hooks/useChainId' import useAsync from '@/hooks/useAsync' import { MethodDetails } from '@/components/transactions/TxDetails/TxData/DecodedData/MethodDetails' import ErrorMessage from '../ErrorMessage' -import Summary from '@/components/transactions/TxDetails/Summary' +import Summary, { PartialSummary } from '@/components/transactions/TxDetails/Summary' import { trackEvent, MODALS_EVENTS } from '@/services/analytics' import { isEmptyHexData } from '@/utils/hex' import ApprovalEditor from '@/components/tx/ApprovalEditor' import { ErrorBoundary } from '@sentry/react' import { getNativeTransferData } from '@/services/tx/tokenTransferParams' import Multisend from '@/components/transactions/TxDetails/TxData/DecodedData/Multisend' +import InfoIcon from '@/public/images/notifications/info.svg' +import ExternalLink from '@/components/common/ExternalLink' +import { HelpCenterArticle } from '@/config/constants' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import accordionCss from '@/styles/accordion.module.css' type DecodedTxProps = { tx?: SafeTransaction @@ -55,10 +69,10 @@ const DecodedTx = ({ tx, txId }: DecodedTxProps): ReactElement | null => { trackEvent({ ...MODALS_EVENTS.TX_DETAILS, label: expanded ? 'Open' : 'Close' }) } - if (isRejection) return null + if (isRejection || !tx) return null return ( - <Box mb={2}> + <div> {approvalEditorTx && ( <ErrorBoundary fallback={<div>Error parsing data</div>}> <ApprovalEditor safeTransaction={tx} /> @@ -81,7 +95,11 @@ const DecodedTx = ({ tx, txId }: DecodedTxProps): ReactElement | null => { )} <Accordion elevation={0} onChange={onChangeExpand} sx={!tx ? { pointerEvents: 'none' } : undefined}> - <AccordionSummary>Transaction details</AccordionSummary> + <AccordionSummary expandIcon={<ExpandMoreIcon />} className={accordionCss.accordion}> + <span style={{ flex: 1 }}>Transaction details</span> + + {decodedData ? decodedData.method : tx?.data.operation === OperationType.DelegateCall ? 'Delegate call' : ''} + </AccordionSummary> <AccordionDetails> {decodedData ? ( @@ -92,22 +110,49 @@ const DecodedTx = ({ tx, txId }: DecodedTxProps): ReactElement | null => { decodedDataLoading && <Skeleton /> )} - {txDetails ? ( - <Box mt={2}> - <Typography variant="overline" fontWeight="bold" color="border.main"> - Advanced details - </Typography> - <Summary txDetails={txDetails} defaultExpanded /> - </Box> - ) : txDetailsError ? ( - <ErrorMessage error={txDetailsError}>Failed loading transaction details</ErrorMessage> - ) : ( - txDetailsLoading && <Skeleton /> - )} + <Box mt={2}> + <Typography variant="overline" fontWeight="bold" color="border.main" display="flex" alignItems="center"> + Advanced details + <Tooltip + title={ + <> + We recommend not changing the default values unless necessary.{' '} + <ExternalLink href={HelpCenterArticle.ADVANCED_PARAMS} title="Learn more about advanced details"> + Learn more about advanced details + </ExternalLink> + . + </> + } + arrow + placement="top" + > + <span> + <SvgIcon + component={InfoIcon} + inheritViewBox + color="border" + fontSize="small" + sx={{ + verticalAlign: 'middle', + ml: 0.5, + }} + /> + </span> + </Tooltip> + </Typography> + + {txDetails ? <Summary txDetails={txDetails} defaultExpanded /> : tx && <PartialSummary safeTx={tx} />} + + {txDetailsLoading && <Skeleton />} + + {txDetailsError && ( + <ErrorMessage error={txDetailsError}>Failed loading all transaction details</ErrorMessage> + )} + </Box> </AccordionDetails> </Accordion> - </Box> + </div> ) } -export default DecodedTx +export default memo(DecodedTx) diff --git a/src/components/tx/ErrorMessage/index.tsx b/src/components/tx/ErrorMessage/index.tsx index 4d2eea859b..5f6cc07932 100644 --- a/src/components/tx/ErrorMessage/index.tsx +++ b/src/components/tx/ErrorMessage/index.tsx @@ -14,7 +14,7 @@ const ErrorMessage = ({ children: ReactNode error?: Error & { reason?: string } className?: string - level?: 'error' | 'info' + level?: 'error' | 'warning' | 'info' }): ReactElement => { const [showDetails, setShowDetails] = useState<boolean>(false) @@ -24,7 +24,7 @@ const ErrorMessage = ({ } return ( - <div className={classNames(css.container, css[level], className)}> + <div className={classNames(css.container, css[level], className, 'errorMessage')}> <div className={css.message}> <SvgIcon component={level === 'info' ? InfoIcon : WarningIcon} inheritViewBox fontSize="small" /> diff --git a/src/components/tx/ErrorMessage/styles.module.css b/src/components/tx/ErrorMessage/styles.module.css index 5fe129aa3c..1fda0fddc0 100644 --- a/src/components/tx/ErrorMessage/styles.module.css +++ b/src/components/tx/ErrorMessage/styles.module.css @@ -9,6 +9,11 @@ color: var(--color-error-dark); } +.container.warning { + background-color: var(--color-warning-background); + color: var(--color-warning-dark); +} + .container.info { background-color: var(--color-info-background); color: var(--color-primary-main); diff --git a/src/components/tx/ExecuteCheckbox/index.tsx b/src/components/tx/ExecuteCheckbox/index.tsx index 9d05d8dc8b..b8b59250e2 100644 --- a/src/components/tx/ExecuteCheckbox/index.tsx +++ b/src/components/tx/ExecuteCheckbox/index.tsx @@ -1,48 +1,45 @@ import type { ChangeEvent, ReactElement } from 'react' -import { Checkbox, FormControlLabel, SvgIcon, Tooltip } from '@mui/material' -import InfoIcon from '@/public/images/notifications/info.svg' +import { FormControlLabel, RadioGroup, Radio } from '@mui/material' import { trackEvent, MODALS_EVENTS } from '@/services/analytics' +import { useAppDispatch, useAppSelector } from '@/store' +import { selectSettings, setTransactionExecution } from '@/store/settingsSlice' -const ExecuteCheckbox = ({ - checked, - onChange, - disabled = false, -}: { - checked: boolean - onChange: (checked: boolean) => void - disabled?: boolean -}): ReactElement => { - const handleChange = (_: ChangeEvent<HTMLInputElement>, checked: boolean) => { +import css from './styles.module.css' + +const ExecuteCheckbox = ({ onChange }: { onChange: (checked: boolean) => void }): ReactElement => { + const settings = useAppSelector(selectSettings) + const dispatch = useAppDispatch() + + const handleChange = (_: ChangeEvent<HTMLInputElement>, value: string) => { + const checked = value === 'true' trackEvent({ ...MODALS_EVENTS.EXECUTE_TX, label: checked }) + dispatch(setTransactionExecution(checked)) onChange(checked) } - const infoIcon = ( - <Tooltip - title={ - disabled - ? 'This transaction is fully signed and will be executed.' - : 'If you want to sign the transaction now but manually execute it later, uncheck this box.' - } - > - <span> - <SvgIcon - component={InfoIcon} - inheritViewBox - fontSize="small" - color="border" - sx={{ verticalAlign: 'middle', marginLeft: 0.5 }} - /> - </span> - </Tooltip> - ) - return ( - <FormControlLabel - control={<Checkbox checked={checked} onChange={handleChange} disabled={disabled} />} - label={<>Execute transaction {infoIcon}</>} - sx={{ mb: 1 }} - /> + <RadioGroup row value={String(settings.transactionExecution)} onChange={handleChange} className={css.group}> + <FormControlLabel + value="true" + label={ + <> + Yes, <b>execute</b> + </> + } + control={<Radio />} + className={css.radio} + /> + <FormControlLabel + value="false" + label={ + <> + No, only <b>sign</b> + </> + } + control={<Radio />} + className={css.radio} + /> + </RadioGroup> ) } diff --git a/src/components/tx/ExecuteCheckbox/styles.module.css b/src/components/tx/ExecuteCheckbox/styles.module.css new file mode 100644 index 0000000000..2cc7af71f5 --- /dev/null +++ b/src/components/tx/ExecuteCheckbox/styles.module.css @@ -0,0 +1,37 @@ +.group { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-2); +} + +.radio { + margin: 0; + border: 1px solid var(--color-border-light); + border-radius: 6px; + padding: 6px 3px; +} + +.select { + margin-top: var(--space-2); +} + +.select :global .MuiFormLabel-root { + color: var(--color-text-primary); + transform: translate(22px, 22px) scale(1); +} + +.select :global .MuiSelect-select { + padding: 22px; + text-align: right; + font-weight: 700; + padding-right: 52px !important; +} + +.select :global .MuiInputBase-root fieldset { + border-color: var(--color-border-light) !important; + border-width: 1px !important; +} + +.select :global .MuiSvgIcon-root { + right: 22px; +} diff --git a/src/components/tx/ExecutionMethodSelector/index.tsx b/src/components/tx/ExecutionMethodSelector/index.tsx index dd2c482400..95e56b9d4a 100644 --- a/src/components/tx/ExecutionMethodSelector/index.tsx +++ b/src/components/tx/ExecutionMethodSelector/index.tsx @@ -37,7 +37,7 @@ export const ExecutionMethodSelector = ({ return ( <Box className={css.container} sx={{ borderRadius: ({ shape }) => `${shape.borderRadius}px` }}> - <Box className={css.method}> + <div className={css.method}> <FormControl sx={{ display: 'flex' }}> {!noLabel ? ( <Typography variant="body2" className={css.label}> @@ -69,7 +69,7 @@ export const ExecutionMethodSelector = ({ /> </RadioGroup> </FormControl> - </Box> + </div> {shouldRelay && relays ? <SponsoredBy relays={relays} tooltip={tooltip} /> : null} </Box> diff --git a/src/components/tx/ExecutionMethodSelector/styles.module.css b/src/components/tx/ExecutionMethodSelector/styles.module.css index 76109da156..00dab746c7 100644 --- a/src/components/tx/ExecutionMethodSelector/styles.module.css +++ b/src/components/tx/ExecutionMethodSelector/styles.module.css @@ -3,11 +3,10 @@ } .method { - padding: var(--space-1) var(--space-2); + padding: var(--space-2) var(--space-2) var(--space-1) var(--space-2); } .label { - padding-top: var(--space-1); color: var(--color-text-secondary); } diff --git a/src/components/tx/GasParams/index.tsx b/src/components/tx/GasParams/index.tsx index 265bc72449..c5b266ec22 100644 --- a/src/components/tx/GasParams/index.tsx +++ b/src/components/tx/GasParams/index.tsx @@ -7,6 +7,7 @@ import { type AdvancedParameters } from '../AdvancedParams/types' import { trackEvent, MODALS_EVENTS } from '@/services/analytics' import classnames from 'classnames' import css from './styles.module.css' +import accordionCss from '@/styles/accordion.module.css' const GasDetail = ({ name, value, isLoading }: { name: string; value: string; isLoading: boolean }): ReactElement => { const valueSkeleton = <Skeleton variant="text" sx={{ minWidth: '5em' }} /> @@ -68,7 +69,7 @@ const GasParams = ({ onChange={onChangeExpand} className={classnames({ [css.withExecutionMethod]: isExecution })} > - <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <AccordionSummary expandIcon={<ExpandMoreIcon />} className={accordionCss.accordion}> {isExecution ? ( <Typography display="flex" alignItems="center" justifyContent="space-between" width={1}> <span>Estimated fee </span> diff --git a/src/components/tx/NonceForm/index.tsx b/src/components/tx/NonceForm/index.tsx deleted file mode 100644 index 67421f21c8..0000000000 --- a/src/components/tx/NonceForm/index.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { memo, useMemo } from 'react' -import type { ReactElement } from 'react' -import { useController, useFormContext, useWatch } from 'react-hook-form' -import { Autocomplete, IconButton, InputAdornment, MenuItem, Tooltip } from '@mui/material' -import RotateLeftIcon from '@mui/icons-material/RotateLeft' -import useSafeInfo from '@/hooks/useSafeInfo' -import NumberField from '@/components/common/NumberField' -import useTxQueue, { useQueuedTxByNonce } from '@/hooks/useTxQueue' -import { isMultisigExecutionInfo, isTransactionListItem } from '@/utils/transaction-guards' -import { uniqBy } from 'lodash' -import { getTransactionType } from '@/hooks/useTransactionType' -import useAddressBook from '@/hooks/useAddressBook' -import { getLatestTransactions } from '@/utils/tx-list' -import type { MenuItemProps } from '@mui/material' - -type NonceFormProps = { - name: string - nonce: number - recommendedNonce?: number - readonly?: boolean -} - -const NonceFormOption = memo(function NonceFormOption({ - nonce, - menuItemProps, -}: { - nonce: number - menuItemProps: MenuItemProps -}): ReactElement { - const addressBook = useAddressBook() - const transactions = useQueuedTxByNonce(nonce) - - const label = useMemo(() => { - const [{ transaction }] = getLatestTransactions(transactions) - return getTransactionType(transaction, addressBook).text - }, [addressBook, transactions]) - - return ( - <MenuItem key={nonce} {...menuItemProps}> - {nonce} ({label} transaction) - </MenuItem> - ) -}) - -const NonceForm = ({ name, nonce, recommendedNonce, readonly }: NonceFormProps): ReactElement => { - const { safe } = useSafeInfo() - const safeNonce = safe.nonce || 0 - - // Initialise form field - const { setValue, control } = useFormContext() || {} - const { - field: { ref, onBlur, onChange, value }, - fieldState, - } = useController({ - name, - control, - defaultValue: nonce, - rules: { - required: true, - validate: (val: number) => { - if (!Number.isInteger(val)) { - return 'Nonce must be an integer' - } else if (val < safeNonce) { - return `Nonce can't be lower than ${safeNonce}` - } - }, - }, - }) - - // Autocomplete options - const { page } = useTxQueue() - const queuedTxs = useMemo(() => { - if (!page || page.results.length === 0) { - return [] - } - - const txs = page.results.filter(isTransactionListItem).map((item) => item.transaction) - - return uniqBy(txs, (tx) => { - return isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : '' - }) - }, [page]) - - const options = useMemo(() => { - return queuedTxs - .map((tx) => (isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : undefined)) - .filter((nonce) => nonce !== undefined) - }, [queuedTxs]) - - // Warn about a higher nonce - const editableNonce = useWatch({ name, control, exact: true }) - const nonceWarning = - recommendedNonce != null && editableNonce > recommendedNonce ? `Recommended nonce is ${recommendedNonce}` : '' - const label = fieldState.error?.message || nonceWarning || 'Safe Account transaction nonce' - - const onResetNonce = () => { - if (recommendedNonce != null) { - setValue(name, recommendedNonce, { shouldValidate: true }) - } - } - - return ( - <Autocomplete - value={value} - freeSolo - // On option select or free text entry - onInputChange={(_, value) => { - onChange(value ? Number(value) : '') - }} - options={options} - disabled={nonce == null || readonly} - getOptionLabel={(option) => option.toString()} - renderOption={(props, option: number) => <NonceFormOption menuItemProps={props} nonce={option} />} - disableClearable - componentsProps={{ - paper: { - elevation: 2, - }, - }} - renderInput={(params) => ( - <NumberField - {...params} - name={name} - onBlur={onBlur} - inputRef={ref} - error={!!fieldState.error} - label={label} - InputProps={{ - ...params.InputProps, - endAdornment: !readonly && - recommendedNonce !== undefined && - recommendedNonce !== params.inputProps.value && ( - <InputAdornment position="end"> - <Tooltip title="Reset to recommended nonce"> - <IconButton onClick={onResetNonce} size="small" color="primary"> - <RotateLeftIcon /> - </IconButton> - </Tooltip> - </InputAdornment> - ), - readOnly: readonly, - }} - InputLabelProps={{ - ...params.InputLabelProps, - shrink: true, - }} - /> - )} - /> - ) -} - -export default NonceForm diff --git a/src/components/tx/SendFromBlock/index.tsx b/src/components/tx/SendFromBlock/index.tsx index b939c553fa..5c1dc7f57b 100644 --- a/src/components/tx/SendFromBlock/index.tsx +++ b/src/components/tx/SendFromBlock/index.tsx @@ -5,6 +5,7 @@ import css from './styles.module.css' import useSafeAddress from '@/hooks/useSafeAddress' import EthHashInfo from '@/components/common/EthHashInfo' +// TODO: Remove this file after replacing in all tx flow components const SendFromBlock = ({ title }: { title?: string }): ReactElement => { const address = useSafeAddress() diff --git a/src/components/tx/SendToBlock/index.tsx b/src/components/tx/SendToBlock/index.tsx index cf86ba3744..dc6618d5cf 100644 --- a/src/components/tx/SendToBlock/index.tsx +++ b/src/components/tx/SendToBlock/index.tsx @@ -1,6 +1,7 @@ import { Box, Typography } from '@mui/material' import EthHashInfo from '@/components/common/EthHashInfo' +// TODO: Remove this file after replacing in all tx flow components const SendToBlock = ({ address, title = 'Recipient' }: { address: string; title?: string }) => { return ( <Box mb={3}> diff --git a/src/components/tx/SignOrExecuteForm/ConfirmationTitle.tsx b/src/components/tx/SignOrExecuteForm/ConfirmationTitle.tsx new file mode 100644 index 0000000000..58ecda7b4d --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/ConfirmationTitle.tsx @@ -0,0 +1,29 @@ +import { SvgIcon, Typography } from '@mui/material' +import EditIcon from '@/public/images/common/edit.svg' +import css from './styles.module.css' + +export enum ConfirmationTitleTypes { + sign = 'confirm', + execute = 'execute', +} + +const ConfirmationTitle = ({ isCreation, variant }: { isCreation?: boolean; variant: ConfirmationTitleTypes }) => { + return ( + <div className={css.wrapper}> + <div className={`${css.icon} ${variant === ConfirmationTitleTypes.sign ? css.sign : css.execute}`}> + <SvgIcon component={EditIcon} inheritViewBox fontSize="small" /> + </div> + <div> + <Typography variant="h5" sx={{ textTransform: 'capitalize' }}> + {variant} + </Typography> + <Typography variant="body2"> + You're about to {isCreation ? 'create and ' : ''} + {variant} this transaction. + </Typography> + </div> + </div> + ) +} + +export default ConfirmationTitle diff --git a/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx b/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx new file mode 100644 index 0000000000..51b48fa60d --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx @@ -0,0 +1,167 @@ +import { type ReactElement, type SyntheticEvent, useContext, useState } from 'react' +import { Button, CardActions, Divider } from '@mui/material' +import classNames from 'classnames' + +import ErrorMessage from '@/components/tx/ErrorMessage' +import { logError, Errors } from '@/services/exceptions' +import { useCurrentChain } from '@/hooks/useChains' +import { getTxOptions } from '@/utils/transactions' +import useIsValidExecution from '@/hooks/useIsValidExecution' +import CheckWallet from '@/components/common/CheckWallet' +import { useImmediatelyExecutable, useIsExecutionLoop, useTxActions } from './hooks' +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 { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { TxModalContext } from '@/components/tx-flow' +import { SuccessScreen } from '@/components/tx-flow/flows/SuccessScreen' +import useGasLimit from '@/hooks/useGasLimit' +import AdvancedParams, { useAdvancedParams } from '../AdvancedParams' +import { asError } from '@/services/exceptions/utils' + +import css from './styles.module.css' +import commonCss from '@/components/tx-flow/common/styles.module.css' +import { TxSecurityContext } from '../security/shared/TxSecurityContext' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import NonOwnerError from '@/components/tx/SignOrExecuteForm/NonOwnerError' + +const ExecuteForm = ({ + safeTx, + txId, + onSubmit, + disableSubmit = false, + origin, + onlyExecute, +}: SignOrExecuteProps & { + safeTx?: SafeTransaction +}): ReactElement => { + // Form state + const [isSubmittable, setIsSubmittable] = useState<boolean>(true) + const [submitError, setSubmitError] = useState<Error | undefined>() + + // Hooks + const isOwner = useIsSafeOwner() + const currentChain = useCurrentChain() + const { executeTx } = useTxActions() + const [relays] = useRelaysBySafe() + const { setTxFlow } = useContext(TxModalContext) + const { needsRiskConfirmation, isRiskConfirmed, setIsRiskIgnored } = useContext(TxSecurityContext) + + // Check that the transaction is executable + const isCreation = !txId + const isNewExecutableTx = useImmediatelyExecutable() && isCreation + const isExecutionLoop = useIsExecutionLoop() + + // We default to relay, but the option is only shown if we canRelay + const [executionMethod, setExecutionMethod] = useState(ExecutionMethod.RELAY) + + // SC wallets can relay fully signed transactions + const [walletCanRelay] = useWalletCanRelay(safeTx) + + // The transaction can/will be relayed + const canRelay = walletCanRelay && hasRemainingRelays(relays) + const willRelay = canRelay && executionMethod === ExecutionMethod.RELAY + + // Estimate gas limit + const { gasLimit, gasLimitError } = useGasLimit(safeTx) + const [advancedParams, setAdvancedParams] = useAdvancedParams(gasLimit) + + // Check if transaction will fail + const { executionValidationError, isValidExecutionLoading } = useIsValidExecution(safeTx, advancedParams.gasLimit) + + // On modal submit + const handleSubmit = async (e: SyntheticEvent) => { + e.preventDefault() + + if (needsRiskConfirmation && !isRiskConfirmed) { + setIsRiskIgnored(true) + return + } + + setIsSubmittable(false) + setSubmitError(undefined) + + const txOptions = getTxOptions(advancedParams, currentChain) + + try { + const executedTxId = await executeTx(txOptions, safeTx, txId, origin, willRelay) + setTxFlow(<SuccessScreen txId={executedTxId} />, undefined, false) + } catch (_err) { + const err = asError(_err) + logError(Errors._804, err) + setIsSubmittable(true) + setSubmitError(err) + return + } + + onSubmit() + } + + const cannotPropose = !isOwner && !onlyExecute + const submitDisabled = + !safeTx || !isSubmittable || disableSubmit || isValidExecutionLoading || isExecutionLoop || cannotPropose + + return ( + <> + <form onSubmit={handleSubmit}> + <div className={classNames(css.params, { [css.noBottomBorderRadius]: canRelay })}> + <AdvancedParams + willExecute + params={advancedParams} + recommendedGasLimit={gasLimit} + onFormSubmit={setAdvancedParams} + gasLimitError={gasLimitError} + willRelay={willRelay} + /> + + {canRelay && ( + <div className={css.noTopBorder}> + <ExecutionMethodSelector + executionMethod={executionMethod} + setExecutionMethod={setExecutionMethod} + relays={relays} + /> + </div> + )} + </div> + + {/* Error messages */} + {cannotPropose ? ( + <NonOwnerError /> + ) : isExecutionLoop ? ( + <ErrorMessage> + Cannot execute a transaction from the Safe Account itself, please connect a different account. + </ErrorMessage> + ) : executionValidationError || gasLimitError ? ( + <ErrorMessage error={executionValidationError || gasLimitError}> + This transaction will most likely fail.{' '} + {isNewExecutableTx + ? 'To save gas costs, avoid creating the transaction.' + : 'To save gas costs, reject this transaction.'} + </ErrorMessage> + ) : ( + submitError && ( + <ErrorMessage error={submitError}>Error submitting the transaction. Please try again.</ErrorMessage> + ) + )} + + <Divider className={commonCss.nestedDivider} sx={{ pt: 3 }} /> + + <CardActions> + {/* Submit button */} + <CheckWallet allowNonOwner={onlyExecute}> + {(isOk) => ( + <Button variant="contained" type="submit" disabled={!isOk || submitDisabled}> + Submit + </Button> + )} + </CheckWallet> + </CardActions> + </form> + </> + ) +} + +export default ExecuteForm diff --git a/src/components/tx/SignOrExecuteForm/NonOwnerError.tsx b/src/components/tx/SignOrExecuteForm/NonOwnerError.tsx new file mode 100644 index 0000000000..8f773a7048 --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/NonOwnerError.tsx @@ -0,0 +1,11 @@ +import ErrorMessage from '@/components/tx/ErrorMessage' + +const NonOwnerError = () => { + return ( + <ErrorMessage> + You are currently not an owner of this Safe Account and won't be able to submit this transaction. + </ErrorMessage> + ) +} + +export default NonOwnerError diff --git a/src/components/tx/SignOrExecuteForm/RiskConfirmationError.tsx b/src/components/tx/SignOrExecuteForm/RiskConfirmationError.tsx new file mode 100644 index 0000000000..56dd38eb44 --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/RiskConfirmationError.tsx @@ -0,0 +1,15 @@ +import { useContext } from 'react' +import ErrorMessage from '../ErrorMessage' +import { TxSecurityContext } from '../security/shared/TxSecurityContext' + +const RiskConfirmationError = () => { + const { isRiskConfirmed, isRiskIgnored } = useContext(TxSecurityContext) + + if (isRiskConfirmed || !isRiskIgnored) { + return null + } + + return <ErrorMessage level="warning">Please acknowledge the risk before proceeding.</ErrorMessage> +} + +export default RiskConfirmationError diff --git a/src/components/tx/SignOrExecuteForm/SignForm.tsx b/src/components/tx/SignOrExecuteForm/SignForm.tsx new file mode 100644 index 0000000000..c3d39beb04 --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/SignForm.tsx @@ -0,0 +1,91 @@ +import { type ReactElement, type SyntheticEvent, useContext, useState } from 'react' +import { Button, CardActions, Divider } from '@mui/material' + +import ErrorMessage from '@/components/tx/ErrorMessage' +import { logError, Errors } from '@/services/exceptions' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import CheckWallet from '@/components/common/CheckWallet' +import { useTxActions } from './hooks' +import type { SignOrExecuteProps } from '.' +import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { TxModalContext } from '@/components/tx-flow' +import { asError } from '@/services/exceptions/utils' +import commonCss from '@/components/tx-flow/common/styles.module.css' +import { TxSecurityContext } from '../security/shared/TxSecurityContext' +import NonOwnerError from '@/components/tx/SignOrExecuteForm/NonOwnerError' + +const SignForm = ({ + safeTx, + txId, + onSubmit, + disableSubmit = false, + origin, +}: SignOrExecuteProps & { + safeTx?: SafeTransaction +}): ReactElement => { + // Form state + const [isSubmittable, setIsSubmittable] = useState<boolean>(true) + const [submitError, setSubmitError] = useState<Error | undefined>() + + // Hooks + const isOwner = useIsSafeOwner() + const { signTx } = useTxActions() + const { setTxFlow } = useContext(TxModalContext) + const { needsRiskConfirmation, isRiskConfirmed, setIsRiskIgnored } = useContext(TxSecurityContext) + + // On modal submit + const handleSubmit = async (e: SyntheticEvent) => { + e.preventDefault() + + if (needsRiskConfirmation && !isRiskConfirmed) { + setIsRiskIgnored(true) + return + } + + setIsSubmittable(false) + setSubmitError(undefined) + + try { + await signTx(safeTx, txId, origin) + setTxFlow(undefined) + } catch (_err) { + const err = asError(_err) + logError(Errors._804, err) + setIsSubmittable(true) + setSubmitError(err) + return + } + + onSubmit() + } + + const cannotPropose = !isOwner + const submitDisabled = !safeTx || !isSubmittable || disableSubmit || cannotPropose + + return ( + <form onSubmit={handleSubmit}> + {cannotPropose ? ( + <NonOwnerError /> + ) : ( + submitError && ( + <ErrorMessage error={submitError}>Error submitting the transaction. Please try again.</ErrorMessage> + ) + )} + + <Divider className={commonCss.nestedDivider} sx={{ pt: 3 }} /> + + <CardActions> + {/* Submit button */} + <CheckWallet> + {(isOk) => ( + <Button variant="contained" type="submit" disabled={!isOk || submitDisabled}> + Submit + </Button> + )} + </CheckWallet> + </CardActions> + </form> + ) +} + +export default SignForm diff --git a/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.test.tsx b/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.test.tsx deleted file mode 100644 index 2685496be0..0000000000 --- a/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.test.tsx +++ /dev/null @@ -1,778 +0,0 @@ -import { fireEvent, render } from '@/tests/test-utils' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm/index' -import type { SafeSignature, SafeTransaction } from '@safe-global/safe-core-sdk-types' -import * as useSafeInfoHook from '@/hooks/useSafeInfo' -import * as useGasLimitHook from '@/hooks/useGasLimit' -import * as useChainsHook from '@/hooks/useChains' -import * as txSenderDispatch from '@/services/tx/tx-sender/dispatch' -import * as wallet from '@/hooks/wallets/useWallet' -import * as onboard from '@/hooks/wallets/useOnboard' -import * as walletUtils from '@/utils/wallets' -import * as web3 from '@/hooks/wallets/web3' -import * as canRelay from '@/hooks/useWalletCanRelay' -import type { ChainInfo, SafeInfo, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' -import { waitFor } from '@testing-library/react' -import type { ConnectedWallet } from '@/services/onboard' -import * as safeCoreSDK from '@/hooks/coreSDK/safeCoreSDK' -import type Safe from '@safe-global/safe-core-sdk' -import { Web3Provider } from '@ethersproject/providers' -import { ethers } from 'ethers' -import * as wrongChain from '@/hooks/useIsWrongChain' -import * as useIsValidExecutionHook from '@/hooks/useIsValidExecution' -import * as useChains from '@/hooks/useChains' -import * as useRelaysBySafe from '@/hooks/useRemainingRelays' -import * as useRedefine from '@/components/tx/security/redefine/useRedefine' -import { FEATURES } from '@/utils/chains' -import { type OnboardAPI } from '@web3-onboard/core' -import { SecuritySeverity } from '@/services/security/modules/types' - -jest.mock('@/hooks/useIsWrongChain', () => ({ - __esModule: true, - default: jest.fn(() => false), -})) - -export const createSafeTx = (data = '0x'): SafeTransaction => { - return { - data: { - to: '0x0000000000000000000000000000000000000000', - value: '0x0', - data, - operation: 0, - nonce: 100, - }, - signatures: new Map([]), - addSignature: function (sig: SafeSignature): void { - this.signatures.set(sig.signer, sig) - }, - encodedSignatures: function (): string { - return Array.from(this.signatures) - .map(([, sig]) => { - return [sig.signer, sig.data].join(' = ') - }) - .join('; ') - }, - } as SafeTransaction -} - -describe('SignOrExecuteForm', () => { - let mockSDK - const mockProvider: Web3Provider = new Web3Provider(jest.fn()) - - beforeEach(() => { - jest.resetAllMocks() - - mockSDK = { - isModuleEnabled: jest.fn(() => false), - createTransaction: jest.fn(() => 'asd'), - getTransactionHash: jest.fn(() => '0x10'), - } as unknown as Safe - - jest.spyOn(safeCoreSDK, 'getSafeSDK').mockReturnValue(mockSDK) - jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ - safe: { - version: '1.3.0', - address: { value: ethers.utils.hexZeroPad('0x000', 20) }, - nonce: 100, - threshold: 2, - owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }, { value: ethers.utils.hexZeroPad('0x456', 20) }], - } as SafeInfo, - safeAddress: '0x123', - safeError: undefined, - safeLoading: false, - safeLoaded: true, - })) - jest.spyOn(useGasLimitHook, 'default').mockReturnValue({ - gasLimit: undefined, - gasLimitError: undefined, - gasLimitLoading: false, - }) - jest.spyOn(useIsValidExecutionHook, 'default').mockReturnValue({ - isValidExecution: undefined, - executionValidationError: undefined, - isValidExecutionLoading: false, - }) - jest.spyOn(wallet, 'default').mockReturnValue({ - label: 'MetaMask', - address: ethers.utils.hexZeroPad('0x123', 20), - } as ConnectedWallet) - jest.spyOn(onboard, 'default').mockReturnValue({} as OnboardAPI) - jest.spyOn(web3, 'useWeb3').mockReturnValue(mockProvider) - jest.spyOn(wrongChain, 'default').mockReturnValue(false) - jest - .spyOn(txSenderDispatch, 'dispatchTxProposal') - .mockImplementation(jest.fn(() => Promise.resolve({ txId: '0x12' } as TransactionDetails))) - jest.spyOn(useChains, 'useCurrentChain').mockReturnValue({ - features: [FEATURES.RELAYING, FEATURES.RISK_MITIGATION], - chainId: '5', - } as unknown as ChainInfo) - jest.spyOn(walletUtils, 'isSmartContractWallet').mockResolvedValue(false) - jest.spyOn(useRelaysBySafe, 'useRelaysBySafe').mockReturnValue([{ remaining: 5, limit: 5 }, undefined, false]) - jest.spyOn(canRelay, 'default').mockReturnValue([false, undefined, false]) - }) - - it('displays decoded data if there is a tx', () => { - const mockTx = createSafeTx('0x123') - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} txId="mockTxId" />) - - expect(result.getByText('Transaction details')).toBeInTheDocument() - }) - - it('displays decoded data if tx is a native transfer', () => { - const mockTx = createSafeTx() - - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} />) - - expect(result.queryByText('Transaction details')).toBeInTheDocument() - - // Click on it - fireEvent.click(result.getByText('Transaction details')) - - expect(result.queryByText('Native token transfer')).toBeInTheDocument() - }) - - it('displays an execute checkbox if tx can be executed', () => { - const mockTx = createSafeTx() - const result = render(<SignOrExecuteForm isExecutable={true} txId="123" onSubmit={jest.fn} safeTx={mockTx} />) - - expect(result.getByText('Execute transaction')).toBeInTheDocument() - }) - - it('the execute checkbox is disabled if execution is the only option', () => { - const mockTx = createSafeTx() - const result = render( - <SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} onlyExecute={true} />, - ) - - expect((result.getByRole('checkbox') as HTMLInputElement).disabled).toBe(true) - }) - - it("doesn't display an execute checkbox if nonce is incorrect", () => { - const mockTx = createSafeTx() - - // @ts-ignore - mockTx.data.nonce = 10 - - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} />) - - expect(result.queryByText('Execute transaction')).not.toBeInTheDocument() - }) - - it('displays an execute checkbox if safe threshold is 1', () => { - jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ - safe: { - version: '1.3.0', - address: { value: ethers.utils.hexZeroPad('0x000', 20) }, - nonce: 100, - threshold: 1, - owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }], - } as SafeInfo, - safeAddress: '0x123', - safeError: undefined, - safeLoading: false, - safeLoaded: true, - })) - - const mockTx = createSafeTx() - - const result = render(<SignOrExecuteForm onSubmit={jest.fn} safeTx={mockTx} />) - - expect(result.queryByText('Execute transaction')).toBeInTheDocument() - }) - - describe('hides execution-related errors if it is not an execution', () => { - it('hides the gas limit estimation error', () => { - jest.spyOn(useGasLimitHook, 'default').mockReturnValue({ - gasLimit: undefined, - gasLimitError: new Error('Error estimating gas limit'), - gasLimitLoading: false, - }) - - const mockTx = createSafeTx() - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} />) - - expect( - result.getByText('This transaction will most likely fail. To save gas costs, reject this transaction.'), - ).toBeInTheDocument() - - fireEvent.click(result.getByText('Execute transaction')) - - expect( - result.queryByText('This transaction will most likely fail. To save gas costs, reject this transaction.'), - ).not.toBeInTheDocument() - }) - - it('hides the execution validation error', () => { - jest.spyOn(useIsValidExecutionHook, 'default').mockReturnValue({ - isValidExecution: undefined, - executionValidationError: new Error('Error validating execution'), - isValidExecutionLoading: false, - }) - - const mockTx = createSafeTx() - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} />) - - expect( - result.getByText('This transaction will most likely fail. To save gas costs, reject this transaction.'), - ).toBeInTheDocument() - - fireEvent.click(result.getByText('Execute transaction')) - - expect( - result.queryByText('This transaction will most likely fail. To save gas costs, reject this transaction.'), - ).not.toBeInTheDocument() - }) - }) - - it('displays an error if passed through props', () => { - const mockTx = createSafeTx() - const result = render( - <SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} error={new Error('Some error')} />, - ) - - expect( - result.getByText('This transaction will most likely fail. To save gas costs, reject this transaction.'), - ).toBeInTheDocument() - }) - - it('displays an error and disables the submit button if connected wallet is not an owner', () => { - jest.spyOn(wallet, 'default').mockReturnValue({ - chainId: '1', - label: 'MetaMask', - address: ethers.utils.hexZeroPad('0x789', 20), - } as ConnectedWallet) - - const mockTx = createSafeTx() - const result = render(<SignOrExecuteForm isExecutable={false} onSubmit={jest.fn} safeTx={mockTx} />) - - expect( - result.getByText( - "You are currently not an owner of this Safe Account and won't be able to submit this transaction.", - ), - ).toBeInTheDocument() - expect(result.getByText('Submit')).toBeDisabled() - }) - - it('displays an error and disables the submit button if Safe attempts to execute own transaction', () => { - const address = ethers.utils.hexZeroPad('0x789', 20) - - jest.spyOn(useSafeInfoHook, 'default').mockReturnValue({ - safeAddress: address, - safe: { - version: '1.3.0', - address: { value: address }, - owners: [{ value: address }], - nonce: 100, - } as SafeInfo, - safeLoaded: true, - safeLoading: false, - safeError: undefined, - }) - - jest.spyOn(wallet, 'default').mockReturnValue({ - chainId: '1', - label: 'MetaMask', - address: address, - } as ConnectedWallet) - - const mockTx = createSafeTx() - const result = render(<SignOrExecuteForm isExecutable onlyExecute onSubmit={jest.fn} safeTx={mockTx} />) - - expect( - result.getByText( - 'Cannot execute a transaction from the Safe Account itself, please connect a different account.', - ), - ).toBeInTheDocument() - expect(result.getByText('Submit')).toBeDisabled() - }) - - describe('adjusts the generic error text creating/executing transactions', () => { - it('displays an error for newly created transactions', () => { - jest.spyOn(wallet, 'default').mockReturnValue({ - label: 'MetaMask', - address: ethers.utils.hexZeroPad('0x456', 20), - } as ConnectedWallet) - - jest.spyOn(useSafeInfoHook, 'default').mockReturnValue({ - safeAddress: ethers.utils.hexZeroPad('0x123', 20), - safe: { - version: '1.3.0', - address: { value: ethers.utils.hexZeroPad('0x000', 20) }, - owners: [{ value: ethers.utils.hexZeroPad('0x456', 20) }], - threshold: 1, - } as SafeInfo, - safeLoaded: true, - safeLoading: false, - safeError: undefined, - }) - - const mockTx = createSafeTx() - const result = render( - <SignOrExecuteForm isExecutable={false} onSubmit={jest.fn} safeTx={mockTx} error={new Error('Some error')} />, - ) - - expect( - result.getByText('This transaction will most likely fail. To save gas costs, avoid creating the transaction.'), - ).toBeInTheDocument() - }) - - it('displays an error for transactions being executed', () => { - const mockTx = createSafeTx() - const result = render( - <SignOrExecuteForm - isExecutable={false} - onSubmit={jest.fn} - safeTx={mockTx} - txId="0x123" - error={new Error('Some error')} - />, - ) - - expect( - result.getByText('This transaction will most likely fail. To save gas costs, reject this transaction.'), - ).toBeInTheDocument() - }) - }) - - it('allows execution for non-owners', () => { - jest.spyOn(wallet, 'default').mockReturnValue({ - chainId: '1', - label: 'MetaMask', - address: ethers.utils.hexZeroPad('0x789', 20), - } as ConnectedWallet) - - const mockTx = createSafeTx() - const result = render(<SignOrExecuteForm isExecutable onlyExecute onSubmit={jest.fn} safeTx={mockTx} />) - - expect( - result.queryByText( - "You are currently not an owner of this Safe Account and won't be able to submit this transaction.", - ), - ).not.toBeInTheDocument() - expect(result.getByText('Submit')).not.toBeDisabled() - }) - - it('displays a warning if connected wallet is on a different chain', async () => { - jest.spyOn(wrongChain, 'default').mockReturnValue(true) - jest - .spyOn(useChainsHook, 'useCurrentChain') - .mockReturnValue({ chainName: 'Goerli', features: [] as FEATURES[] } as ChainInfo) - - const mockTx = createSafeTx() - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} />) - - expect(result.getByText('Wallet network switch')).toBeInTheDocument() - expect(result.getByText('Submit')).not.toBeDisabled() - }) - - it('disables the submit button if there is no tx', () => { - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={undefined} />) - - expect(result.getByText('Submit')).toBeDisabled() - }) - - it('disables the submit button while executing', async () => { - const mockTx = createSafeTx() - const result = render( - <SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} onlyExecute={true} />, - ) - - const submitButton = result.getByText('Submit') - expect(submitButton).not.toBeDisabled() - fireEvent.click(submitButton) - - await waitFor(() => expect(submitButton).toBeDisabled()) - }) - - it('disables the submit button if gas limit/execution validity is estimating', async () => { - jest.spyOn(useGasLimitHook, 'default').mockReturnValue({ - gasLimit: undefined, - gasLimitError: undefined, - gasLimitLoading: true, - }) - - jest.spyOn(useIsValidExecutionHook, 'default').mockReturnValue({ - isValidExecution: undefined, - executionValidationError: undefined, - isValidExecutionLoading: true, - }) - - const mockTx = createSafeTx() - const result = render( - <SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} onlyExecute={true} />, - ) - - expect(result.getByText('Estimating...')).toBeDisabled() - }) - - it('relays a 2 out of 2 signed transaction with a connected EOA', async () => { - const signSpy = jest.fn(() => Promise.resolve({})) - const relaySpy = jest.fn() - const proposeSpy = jest.fn(() => Promise.resolve({ txId: '0xdead' })) - jest.spyOn(txSenderDispatch, 'dispatchTxSigning').mockImplementation(signSpy as any) - jest.spyOn(txSenderDispatch, 'dispatchTxProposal').mockImplementation(proposeSpy as any) - jest.spyOn(txSenderDispatch, 'dispatchTxRelay').mockImplementation(relaySpy) - jest.spyOn(canRelay, 'default').mockReturnValue([true, undefined, false]) - - const mockTx = createSafeTx() - - mockTx.addSignature({ - signer: '0x123', - data: '0xEEE', - staticPart: () => '0xEEE', - dynamicPart: () => '', - }) - mockTx.addSignature({ - signer: '0x1234', - data: '0xEEE', - staticPart: () => '0xEEE', - dynamicPart: () => '', - }) - - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} txId="0xdead" />) - - const submitButton = result.getByText('Submit') - fireEvent.click(submitButton) - - await waitFor(() => { - expect(signSpy).not.toHaveBeenCalledTimes(1) - expect(proposeSpy).not.toHaveBeenCalledTimes(1) - expect(relaySpy).toHaveBeenCalledTimes(1) - }) - }) - - it('should not relay a not fully signed transaction with a connected SC wallet', async () => { - const relaySpy = jest.fn() - jest.spyOn(txSenderDispatch, 'dispatchTxProposal').mockImplementation(jest.fn(() => Promise.resolve({})) as any) - jest.spyOn(txSenderDispatch, 'dispatchTxRelay').mockImplementation(relaySpy) - jest.spyOn(canRelay, 'default').mockReturnValue([true, undefined, false]) - - // SC wallet connected - jest.spyOn(walletUtils, 'isSmartContractWallet').mockResolvedValue(true) - - const mockTx = createSafeTx() - - mockTx.addSignature({ - signer: '0x123', - data: '0xEEE', - staticPart: () => '0xEEE', - dynamicPart: () => '', - }) - - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} txId="0xdead" />) - - const submitButton = result.getByText('Submit') - fireEvent.click(submitButton) - - await waitFor(() => expect(relaySpy).toHaveBeenCalledTimes(0)) - }) - - it('relays a fully signed transaction with a connected SC wallet', async () => { - const relaySpy = jest.fn() - jest.spyOn(txSenderDispatch, 'dispatchTxProposal').mockImplementation(jest.fn(() => Promise.resolve({})) as any) - jest.spyOn(txSenderDispatch, 'dispatchTxRelay').mockImplementation(relaySpy) - jest.spyOn(canRelay, 'default').mockReturnValue([true, undefined, false]) - - // SC wallet connected - jest.spyOn(walletUtils, 'isSmartContractWallet').mockResolvedValue(true) - - const mockTx = createSafeTx() - - mockTx.addSignature({ - signer: '0x123', - data: '0xEEE', - staticPart: () => '0xEEE', - dynamicPart: () => '', - }) - - mockTx.addSignature({ - signer: '0x345', - data: '0xAAA', - staticPart: () => '0xAAA', - dynamicPart: () => '', - }) - - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} txId="0xdead" />) - - const submitButton = result.getByText('Submit') - fireEvent.click(submitButton) - - await waitFor(() => expect(relaySpy).toHaveBeenCalledTimes(1)) - }) - - it('relays a fully signed transaction with a connected EOA', async () => { - const relaySpy = jest.fn() - jest.spyOn(txSenderDispatch, 'dispatchTxProposal').mockImplementation(jest.fn(() => Promise.resolve({})) as any) - jest.spyOn(txSenderDispatch, 'dispatchTxRelay').mockImplementation(relaySpy) - jest.spyOn(canRelay, 'default').mockReturnValue([true, undefined, false]) - - const mockTx = createSafeTx() - - mockTx.addSignature({ - signer: '0x123', - data: '0xEEE', - staticPart: () => '0xEEE', - dynamicPart: () => '', - }) - - mockTx.addSignature({ - signer: '0x345', - data: '0xAAA', - staticPart: () => '0xAAA', - dynamicPart: () => '', - }) - - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} txId="0xdead" />) - - const submitButton = result.getByText('Submit') - fireEvent.click(submitButton) - - await waitFor(() => expect(relaySpy).toHaveBeenCalledTimes(1)) - }) - - it('executes a transaction with the connected wallet if chosen instead of relaying', async () => { - jest.spyOn(useRelaysBySafe, 'useRelaysBySafe').mockReturnValue([{ remaining: 5, limit: 5 }, undefined, false]) - jest.spyOn(canRelay, 'default').mockReturnValue([true, undefined, false]) - - const executionSpy = jest.fn() - jest - .spyOn(txSenderDispatch, 'dispatchTxProposal') - .mockImplementation(jest.fn(() => Promise.resolve({} as TransactionDetails))) - - jest.spyOn(txSenderDispatch, 'dispatchTxExecution').mockImplementation(executionSpy) - - const mockTx = createSafeTx() - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} />) - - const walletOptionRadio = await result.findByText('Connected wallet') - expect(walletOptionRadio).toBeInTheDocument() - fireEvent.click(walletOptionRadio) - - const submitButton = result.getByText('Submit') - fireEvent.click(submitButton) - - await waitFor(() => expect(executionSpy).toHaveBeenCalledTimes(1)) - }) - - it('when there are no remaining relays, there should be no option to select the execution method', async () => { - jest.spyOn(useRelaysBySafe, 'useRelaysBySafe').mockReturnValue([{ remaining: 0, limit: 5 }, undefined, false]) - - const executionSpy = jest.fn() - jest - .spyOn(txSenderDispatch, 'dispatchTxProposal') - .mockImplementation(jest.fn(() => Promise.resolve({} as TransactionDetails))) - - jest.spyOn(txSenderDispatch, 'dispatchTxExecution').mockImplementation(executionSpy) - - const mockTx = createSafeTx() - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} />) - - const walletOptionRadio = result.queryByText('Connected wallet') - - expect(walletOptionRadio).not.toBeInTheDocument() - }) - - it('executes a transaction with the connected wallet if relaying is not available', async () => { - jest.spyOn(useRelaysBySafe, 'useRelaysBySafe').mockReturnValue([{ remaining: 0, limit: 5 }, undefined, false]) - - const executionSpy = jest.fn() - jest - .spyOn(txSenderDispatch, 'dispatchTxProposal') - .mockImplementation(jest.fn(() => Promise.resolve({} as TransactionDetails))) - - jest.spyOn(txSenderDispatch, 'dispatchTxExecution').mockImplementation(executionSpy) - - const mockTx = createSafeTx() - const result = render(<SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} />) - - const submitButton = result.getByText('Submit') - fireEvent.click(submitButton) - - await waitFor(() => expect(executionSpy).toHaveBeenCalledTimes(1)) - }) - - it('signs a transaction', async () => { - const mockTx = createSafeTx() - - const signSpy = jest.fn(() => Promise.resolve({} as SafeTransaction)) - const proposeSpy = jest.fn(() => Promise.resolve({} as TransactionDetails)) - - jest.spyOn(txSenderDispatch, 'dispatchTxSigning').mockImplementation(signSpy) - jest.spyOn(txSenderDispatch, 'dispatchTxProposal').mockImplementation(proposeSpy) - jest.spyOn(walletUtils, 'isSmartContractWallet').mockImplementation(() => Promise.resolve(false)) - - const result = render(<SignOrExecuteForm onSubmit={jest.fn} safeTx={mockTx} />) - - const submitButton = result.getByText('Submit') - fireEvent.click(submitButton) - - await waitFor(() => expect(signSpy).toHaveBeenCalledTimes(1)) - expect(proposeSpy).toHaveBeenCalledTimes(1) - }) - - it('smart contract wallets have to propose when creating a tx with an on-chain signature', async () => { - const mockTx = createSafeTx() - - const onChainSignSpy = jest.fn(() => Promise.resolve()) - const proposeSpy = jest.fn(() => Promise.resolve({} as TransactionDetails)) - - jest.spyOn(txSenderDispatch, 'dispatchOnChainSigning').mockImplementation(onChainSignSpy) - jest.spyOn(txSenderDispatch, 'dispatchTxProposal').mockImplementation(proposeSpy) - jest.spyOn(walletUtils, 'isSmartContractWallet').mockImplementation(() => Promise.resolve(true)) - - const result = render(<SignOrExecuteForm onSubmit={jest.fn} safeTx={mockTx} />) - - const submitButton = result.getByText('Submit') - fireEvent.click(submitButton) - - await waitFor(() => expect(onChainSignSpy).toHaveBeenCalledTimes(1)) - expect(proposeSpy).toHaveBeenCalled() - }) - - it('smart contract wallets should not propose when on-chain signing an existing transactions', async () => { - const mockTx = createSafeTx() - - const onChainSignSpy = jest.fn(() => Promise.resolve()) - const proposeSpy = jest.fn(() => Promise.resolve({} as TransactionDetails)) - - jest.spyOn(txSenderDispatch, 'dispatchOnChainSigning').mockImplementation(onChainSignSpy) - jest.spyOn(txSenderDispatch, 'dispatchTxProposal').mockImplementation(proposeSpy) - jest.spyOn(walletUtils, 'isSmartContractWallet').mockImplementation(() => Promise.resolve(true)) - - const result = render(<SignOrExecuteForm txId="0x123" onSubmit={jest.fn} safeTx={mockTx} />) - - const submitButton = result.getByText('Submit') - fireEvent.click(submitButton) - - await waitFor(() => expect(onChainSignSpy).toHaveBeenCalledTimes(1)) - expect(proposeSpy).not.toHaveBeenCalled() - }) - - it('displays an error if execution submission fails', async () => { - jest - .spyOn(txSenderDispatch, 'dispatchTxExecution') - .mockImplementation(jest.fn(() => Promise.reject('Error while dispatching'))) - jest - .spyOn(txSenderDispatch, 'dispatchTxSigning') - .mockImplementation(jest.fn(() => Promise.reject('Error while dispatching'))) - jest - .spyOn(txSenderDispatch, 'dispatchTxRelay') - .mockImplementation(jest.fn(() => Promise.reject('Error while dispatching'))) - - const mockTx = createSafeTx() - const result = render( - <SignOrExecuteForm isExecutable={true} onSubmit={jest.fn} safeTx={mockTx} onlyExecute={true} txId="123" />, - ) - - const submitButton = result.getByText('Submit') - fireEvent.click(submitButton) - - await waitFor(() => { - expect(result.getByText('Error submitting the transaction. Please try again.')).toBeInTheDocument() - }) - }) - - it('requires a confirmation for high risk transactions', async () => { - const mockTx = createSafeTx() - - const signSpy = jest.fn(() => Promise.resolve({} as SafeTransaction)) - const proposeSpy = jest.fn(() => Promise.resolve({} as TransactionDetails)) - - jest.spyOn(txSenderDispatch, 'dispatchTxSigning').mockImplementation(signSpy) - jest.spyOn(txSenderDispatch, 'dispatchTxProposal').mockImplementation(proposeSpy) - jest.spyOn(walletUtils, 'isSmartContractWallet').mockImplementation(() => Promise.resolve(false)) - jest.spyOn(useChains, 'useHasFeature').mockImplementation((feature: FEATURES) => { - return feature === FEATURES.RISK_MITIGATION - }) - jest.spyOn(useRedefine, 'useRedefine').mockReturnValue([ - { - severity: SecuritySeverity.HIGH, - payload: { - errors: [], - issues: [ - { - category: 'TEST_CATEGORY', - description: { - short: 'High test issue', - long: 'This is just a test', - }, - severity: SecuritySeverity.HIGH, - }, - ], - }, - }, - undefined, - false, - ]) - - const result = render(<SignOrExecuteForm onSubmit={jest.fn} safeTx={mockTx} />) - - const submitButton = result.getByText('Submit') - expect(submitButton).toBeDisabled() - - expect(result.baseElement).toHaveTextContent('High issue') - expect(result.baseElement).toHaveTextContent('High test issue') - expect(result.baseElement).toHaveTextContent('I understand the risks and would like to continue this transaction') - - const confirmationBox = result.getByText('I understand the risks and would like to continue this transaction') - fireEvent.click(confirmationBox) - expect(submitButton).toBeEnabled() - fireEvent.click(submitButton) - - await waitFor(() => { - expect(signSpy).toHaveBeenCalledTimes(1) - expect(proposeSpy).toHaveBeenCalledTimes(1) - }) - }) - - it('requires no confirmation for low / no risk transactions', async () => { - const mockTx = createSafeTx() - - const signSpy = jest.fn(() => Promise.resolve({} as SafeTransaction)) - const proposeSpy = jest.fn(() => Promise.resolve({} as TransactionDetails)) - - jest.spyOn(txSenderDispatch, 'dispatchTxSigning').mockImplementation(signSpy) - jest.spyOn(txSenderDispatch, 'dispatchTxProposal').mockImplementation(proposeSpy) - jest.spyOn(walletUtils, 'isSmartContractWallet').mockImplementation(() => Promise.resolve(false)) - jest.spyOn(useChains, 'useHasFeature').mockImplementation((feature: FEATURES) => { - return feature === FEATURES.RISK_MITIGATION - }) - jest.spyOn(useRedefine, 'useRedefine').mockReturnValue([ - { - severity: SecuritySeverity.LOW, - payload: { - errors: [], - issues: [ - { - category: 'TEST_CATEGORY', - description: { - short: 'Low test issue', - long: 'This is just a test', - }, - severity: SecuritySeverity.LOW, - }, - ], - }, - }, - undefined, - false, - ]) - - const result = render(<SignOrExecuteForm onSubmit={jest.fn} safeTx={mockTx} />) - - const submitButton = result.getByText('Submit') - expect(submitButton).toBeEnabled() - - expect(result.baseElement).toHaveTextContent('Low issue') - expect(result.baseElement).toHaveTextContent('Low test issue') - expect(result.baseElement).not.toHaveTextContent( - 'I understand the risks and would like to continue this transaction', - ) - - fireEvent.click(submitButton) - - await waitFor(() => { - expect(signSpy).toHaveBeenCalledTimes(1) - expect(proposeSpy).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/src/components/tx/SignOrExecuteForm/SubmitButton.tsx b/src/components/tx/SignOrExecuteForm/SubmitButton.tsx index a3fa52476c..8412086eab 100644 --- a/src/components/tx/SignOrExecuteForm/SubmitButton.tsx +++ b/src/components/tx/SignOrExecuteForm/SubmitButton.tsx @@ -1,7 +1,7 @@ import CheckWallet from '@/components/common/CheckWallet' import { Button } from '@mui/material' import { useContext } from 'react' -import { TransactionSecurityContext } from '../security/TransactionSecurityContext' +import { TxSecurityContext } from '../security/shared/TxSecurityContext' const SubmitButton = ({ willExecute, @@ -12,7 +12,7 @@ const SubmitButton = ({ submitDisabled: boolean isEstimating: boolean }) => { - const { needsRiskConfirmation, isRiskConfirmed } = useContext(TransactionSecurityContext) + const { needsRiskConfirmation, isRiskConfirmed } = useContext(TxSecurityContext) const disableButton = submitDisabled || (needsRiskConfirmation && !isRiskConfirmed) return ( diff --git a/src/components/tx/SignOrExecuteForm/TxChecks.tsx b/src/components/tx/SignOrExecuteForm/TxChecks.tsx new file mode 100644 index 0000000000..d1fb2444fa --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/TxChecks.tsx @@ -0,0 +1,31 @@ +import { type ReactElement, useContext } from 'react' +import { TxSimulation, TxSimulationMessage } from '@/components/tx/security/tenderly' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { Box, Typography } from '@mui/material' +import { Redefine, RedefineMessage } from '@/components/tx/security/redefine' + +import css from './styles.module.css' + +const TxChecks = (): ReactElement => { + const { safeTx } = useContext(SafeTxContext) + + return ( + <> + <Typography variant="h5">Transaction checks</Typography> + + <TxSimulation disabled={false} transactions={safeTx} /> + + <Box className={css.mobileTxCheckMessages}> + <TxSimulationMessage /> + </Box> + + <Redefine /> + + <Box className={css.mobileTxCheckMessages}> + <RedefineMessage /> + </Box> + </> + ) +} + +export default TxChecks diff --git a/src/components/tx/SignOrExecuteForm/hooks.test.ts b/src/components/tx/SignOrExecuteForm/hooks.test.ts index 94607bba31..d40e5c1861 100644 --- a/src/components/tx/SignOrExecuteForm/hooks.test.ts +++ b/src/components/tx/SignOrExecuteForm/hooks.test.ts @@ -1,18 +1,67 @@ import { renderHook } from '@/tests/test-utils' import { ethers } from 'ethers' -import { type SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeSignature, SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { type ConnectedWallet } from '@/services/onboard' import * as useSafeInfoHook from '@/hooks/useSafeInfo' import * as wallet from '@/hooks/wallets/useWallet' import * as walletHooks from '@/utils/wallets' import * as pending from '@/hooks/usePendingTxs' -import * as txSender from '@/services/tx/tx-sender' +import * as txSender from '@/services/tx/tx-sender/dispatch' +import * as onboardHooks from '@/hooks/wallets/useOnboard' +import { type OnboardAPI } from '@web3-onboard/core' import { useImmediatelyExecutable, useIsExecutionLoop, useTxActions, useValidateNonce } from './hooks' -import { createSafeTx } from './SignOrExecuteForm.test' + +const createSafeTx = (data = '0x'): SafeTransaction => { + return { + data: { + to: '0x0000000000000000000000000000000000000000', + value: '0x0', + data, + operation: 0, + nonce: 100, + }, + signatures: new Map([]), + addSignature: function (sig: SafeSignature): void { + this.signatures.set(sig.signer, sig) + }, + encodedSignatures: function (): string { + return Array.from(this.signatures) + .map(([, sig]) => { + return [sig.signer, sig.data].join(' = ') + }) + .join('; ') + }, + } as SafeTransaction +} describe('SignOrExecute hooks', () => { beforeEach(() => { jest.clearAllMocks() + + // Onboard + jest.spyOn(onboardHooks, 'default').mockReturnValue({ + setChain: jest.fn(), + state: { + get: () => ({ + wallets: [ + { + label: 'MetaMask', + accounts: [{ address: '0x1234567890000000000000000000000000000000' }], + connected: true, + chains: [{ id: '1' }], + }, + ], + }), + }, + } as unknown as OnboardAPI) + + // Wallet + jest.spyOn(wallet, 'default').mockReturnValue({ + chainId: '1', + label: 'MetaMask', + address: '0x1234567890000000000000000000000000000000', + } as unknown as ConnectedWallet) }) describe('useValidateNonce', () => { @@ -24,6 +73,7 @@ describe('SignOrExecute hooks', () => { nonce: 100, threshold: 2, owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }, { value: ethers.utils.hexZeroPad('0x456', 20) }], + chainId: '1', } as SafeInfo, safeAddress: ethers.utils.hexZeroPad('0x000', 20), safeError: undefined, @@ -44,6 +94,7 @@ describe('SignOrExecute hooks', () => { nonce: 90, threshold: 2, owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }, { value: ethers.utils.hexZeroPad('0x456', 20) }], + chainId: '1', } as SafeInfo, safeAddress: ethers.utils.hexZeroPad('0x000', 20), safeError: undefined, @@ -68,6 +119,7 @@ describe('SignOrExecute hooks', () => { address: { value: address }, owners: [{ value: address }], nonce: 100, + chainId: '1', } as SafeInfo, safeLoaded: true, safeLoading: false, @@ -130,6 +182,7 @@ describe('SignOrExecute hooks', () => { owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }], threshold: 2, nonce: 100, + chainId: '1', } as SafeInfo, safeLoaded: true, safeLoading: false, @@ -152,6 +205,7 @@ describe('SignOrExecute hooks', () => { owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }], threshold: 1, nonce: 100, + chainId: '1', } as SafeInfo, safeLoaded: true, safeLoading: false, @@ -175,6 +229,7 @@ describe('SignOrExecute hooks', () => { nonce: 100, threshold: 2, owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }, { value: ethers.utils.hexZeroPad('0x456', 20) }], + chainId: '1', } as SafeInfo, safeAddress: '0x123', safeError: undefined, @@ -198,6 +253,7 @@ describe('SignOrExecute hooks', () => { nonce: 100, threshold: 2, owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }, { value: ethers.utils.hexZeroPad('0x456', 20) }], + chainId: '1', } as SafeInfo, safeAddress: '0x123', safeError: undefined, @@ -208,15 +264,19 @@ describe('SignOrExecute hooks', () => { jest .spyOn(txSender, 'dispatchTxProposal') .mockImplementation((() => Promise.resolve({ txId: '123' })) as unknown as typeof txSender.dispatchTxProposal) + const signSpy = jest .spyOn(txSender, 'dispatchTxSigning') .mockImplementation(() => Promise.resolve(createSafeTx())) + const onchainSignSpy = jest.spyOn(txSender, 'dispatchOnChainSigning').mockImplementation(() => Promise.resolve()) + const { result } = renderHook(() => useTxActions()) const { signTx } = result.current const id = await signTx(createSafeTx()) expect(signSpy).toHaveBeenCalled() + expect(onchainSignSpy).not.toHaveBeenCalled() expect(id).toBe('123') const id2 = await signTx(createSafeTx(), '456') @@ -234,6 +294,7 @@ describe('SignOrExecute hooks', () => { nonce: 100, threshold: 2, owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }, { value: ethers.utils.hexZeroPad('0x456', 20) }], + chainId: '1', } as SafeInfo, safeAddress: '0x123', safeError: undefined, @@ -262,6 +323,7 @@ describe('SignOrExecute hooks', () => { nonce: 100, threshold: 2, owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }, { value: ethers.utils.hexZeroPad('0x456', 20) }], + chainId: '1', } as SafeInfo, safeAddress: '0x123', safeError: undefined, @@ -293,6 +355,7 @@ describe('SignOrExecute hooks', () => { nonce: 100, threshold: 2, owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }, { value: ethers.utils.hexZeroPad('0x456', 20) }], + chainId: '1', } as SafeInfo, safeAddress: '0x123', safeError: undefined, @@ -324,6 +387,7 @@ describe('SignOrExecute hooks', () => { nonce: 100, threshold: 2, owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }, { value: ethers.utils.hexZeroPad('0x456', 20) }], + chainId: '1', } as SafeInfo, safeAddress: '0x123', safeError: undefined, @@ -347,6 +411,7 @@ describe('SignOrExecute hooks', () => { nonce: 100, threshold: 1, owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }, { value: ethers.utils.hexZeroPad('0x456', 20) }], + chainId: '1', } as SafeInfo, safeAddress: '0x123', safeError: undefined, @@ -386,6 +451,7 @@ describe('SignOrExecute hooks', () => { nonce: 100, threshold: 2, owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }, { value: ethers.utils.hexZeroPad('0x456', 20) }], + chainId: '1', } as SafeInfo, safeAddress: '0x123', safeError: undefined, @@ -435,6 +501,7 @@ describe('SignOrExecute hooks', () => { nonce: 100, threshold: 2, owners: [{ value: ethers.utils.hexZeroPad('0x123', 20) }, { value: ethers.utils.hexZeroPad('0x456', 20) }], + chainId: '1', } as SafeInfo, safeAddress: '0x123', safeError: undefined, diff --git a/src/components/tx/SignOrExecuteForm/hooks.ts b/src/components/tx/SignOrExecuteForm/hooks.ts index 0bbe7d52a8..09a1d5bfc2 100644 --- a/src/components/tx/SignOrExecuteForm/hooks.ts +++ b/src/components/tx/SignOrExecuteForm/hooks.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { type TransactionOptions, type SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { sameString } from '@safe-global/safe-core-sdk/dist/src/utils' import useSafeInfo from '@/hooks/useSafeInfo' import useWallet from '@/hooks/wallets/useWallet' import useOnboard from '@/hooks/wallets/useOnboard' @@ -12,10 +13,10 @@ import { dispatchTxSigning, } from '@/services/tx/tx-sender' import { useHasPendingTxs } from '@/hooks/usePendingTxs' -import { sameString } from '@safe-global/safe-core-sdk/dist/src/utils' import type { ConnectedWallet } from '@/services/onboard' import type { OnboardAPI } from '@web3-onboard/core' -import { hasEnoughSignatures } from '@/utils/transactions' +import { getSafeTxGas, getRecommendedNonce } from '@/services/tx/tx-sender/recommendedNonce' +import useAsync from '@/hooks/useAsync' type TxActions = { signTx: (safeTx?: SafeTransaction, txId?: string, origin?: string) => Promise<string> @@ -28,13 +29,13 @@ type TxActions = { ) => Promise<string> } -function assertTx(safeTx?: SafeTransaction): asserts safeTx { +function assertTx(safeTx: SafeTransaction | undefined): asserts safeTx { if (!safeTx) throw new Error('Transaction not provided') } function assertWallet(wallet: ConnectedWallet | null): asserts wallet { if (!wallet) throw new Error('Wallet not connected') } -function assertOnboard(onboard?: OnboardAPI): asserts onboard { +function assertOnboard(onboard: OnboardAPI | undefined): asserts onboard { if (!onboard) throw new Error('Onboard not connected') } @@ -97,7 +98,7 @@ export const useTxActions = (): TxActions => { assertOnboard(onboard) // Relayed transactions must be fully signed, so request a final signature if needed - if (isRelayed && !hasEnoughSignatures(safeTx, safe)) { + if (isRelayed && safeTx.signatures.size < safe.threshold) { safeTx = await signRelayedTx(safeTx) txId = await proposeTx(wallet.address, safeTx, txId, origin) } @@ -121,7 +122,7 @@ export const useTxActions = (): TxActions => { }, [safe, onboard, wallet]) } -export const useValidateNonce = (safeTx?: SafeTransaction): boolean => { +export const useValidateNonce = (safeTx: SafeTransaction | undefined): boolean => { const { safe } = useSafeInfo() return !!safeTx && safeTx?.data.nonce === safe.nonce } @@ -138,3 +139,43 @@ export const useIsExecutionLoop = (): boolean => { const { safeAddress } = useSafeInfo() return wallet ? sameString(wallet.address, safeAddress) : false } + +export const useRecommendedNonce = (): number | undefined => { + const { safeAddress, safe } = useSafeInfo() + + const [recommendedNonce] = useAsync( + () => { + if (!safe.chainId || !safeAddress) return + + return getRecommendedNonce(safe.chainId, safeAddress) + }, + [safeAddress, safe.chainId, safe.txQueuedTag], // update when tx queue changes + false, // keep old recommended nonce while refreshing to avoid skeleton + ) + + return recommendedNonce +} + +export const useSafeTxGas = (safeTx: SafeTransaction | undefined): number | undefined => { + const { safeAddress, safe } = useSafeInfo() + + // Memoize only the necessary params so that the useAsync hook is not called every time safeTx changes + const safeTxParams = useMemo(() => { + return !safeTx?.data?.to + ? undefined + : { + to: safeTx?.data.to, + value: safeTx?.data?.value, + data: safeTx?.data?.data, + operation: safeTx?.data?.operation, + } + }, [safeTx?.data.to, safeTx?.data.value, safeTx?.data.data, safeTx?.data.operation]) + + const [safeTxGas] = useAsync(() => { + if (!safe.chainId || !safeAddress || !safeTxParams) return + + return getSafeTxGas(safe.chainId, safeAddress, safeTxParams) + }, [safeAddress, safe.chainId, safeTxParams]) + + return safeTxGas +} diff --git a/src/components/tx/SignOrExecuteForm/index.tsx b/src/components/tx/SignOrExecuteForm/index.tsx index bbcbff221c..37d224920f 100644 --- a/src/components/tx/SignOrExecuteForm/index.tsx +++ b/src/components/tx/SignOrExecuteForm/index.tsx @@ -1,39 +1,25 @@ -import { type ReactElement, type ReactNode, type SyntheticEvent, useEffect, useState } from 'react' -import { Box, DialogContent, Typography } from '@mui/material' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' - -import useGasLimit from '@/hooks/useGasLimit' -import ErrorMessage from '@/components/tx/ErrorMessage' -import AdvancedParams, { type AdvancedParameters, useAdvancedParams } from '@/components/tx/AdvancedParams' +import { type ReactElement, type ReactNode, useState, useContext } from 'react' import DecodedTx from '../DecodedTx' import ExecuteCheckbox from '../ExecuteCheckbox' -import { logError, Errors } from '@/services/exceptions' -import { useCurrentChain } from '@/hooks/useChains' -import { getTxOptions } from '@/utils/transactions' -import { TxSimulation } from '@/components/tx/TxSimulation' -import useIsSafeOwner from '@/hooks/useIsSafeOwner' -import useIsValidExecution from '@/hooks/useIsValidExecution' -import { createTx } from '@/services/tx/tx-sender' import { WrongChainWarning } from '../WrongChainWarning' -import { useImmediatelyExecutable, useIsExecutionLoop, useTxActions, useValidateNonce } from './hooks' -import UnknownContractError from './UnknownContractError' -import { useRelaysBySafe } from '@/hooks/useRemainingRelays' -import useWalletCanRelay from '@/hooks/useWalletCanRelay' -import { ExecutionMethod, ExecutionMethodSelector } from '../ExecutionMethodSelector' -import { hasRemainingRelays } from '@/utils/relaying' -import { TransactionSecurityProvider } from '../security/TransactionSecurityContext' +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 { RedefineBalanceChanges } from '../security/redefine/RedefineBalanceChange' -import { RedefineScanResult } from '../security/redefine/RedefineScanResult/RedefineScanResult' -import SubmitButton from './SubmitButton' -import { useAppDispatch, useAppSelector } from '@/store' -import { selectSettings, setTransactionExecution } from '@/store/settingsSlice' +import UnknownContractError from './UnknownContractError' +import RiskConfirmationError from './RiskConfirmationError' -type SignOrExecuteProps = { - safeTx?: SafeTransaction +export type SignOrExecuteProps = { txId?: string - onSubmit: () => void + onSubmit: () => void // Should go to the success screen onSubmit children?: ReactNode - error?: Error isExecutable?: boolean isRejection?: boolean onlyExecute?: boolean @@ -41,232 +27,55 @@ type SignOrExecuteProps = { origin?: string } -const SignOrExecuteForm = ({ - safeTx, - txId, - onSubmit, - children, - onlyExecute = false, - isExecutable = false, - isRejection = false, - disableSubmit = false, - origin, - ...props -}: SignOrExecuteProps): ReactElement => { - const settings = useAppSelector(selectSettings) - - // - // Hooks & variables - // - const [shouldExecute, setShouldExecute] = useState<boolean>(settings.transactionExecution) - const [isSubmittable, setIsSubmittable] = useState<boolean>(true) - const [tx, setTx] = useState<SafeTransaction | undefined>(safeTx) - const [submitError, setSubmitError] = useState<Error | undefined>() - - // Hooks - const isOwner = useIsSafeOwner() - const currentChain = useCurrentChain() - const { signTx, executeTx } = useTxActions() - const [relays] = useRelaysBySafe() - const dispatch = useAppDispatch() - - // Check that the transaction is executable - const isCreation = !txId +const SignOrExecuteForm = (props: SignOrExecuteProps): ReactElement => { + const { transactionExecution } = useAppSelector(selectSettings) + const [shouldExecute, setShouldExecute] = useState<boolean>(transactionExecution) + const isCreation = !props.txId const isNewExecutableTx = useImmediatelyExecutable() && isCreation - const isCorrectNonce = useValidateNonce(tx) - const isExecutionLoop = useIsExecutionLoop() - const canExecute = isCorrectNonce && (isExecutable || isNewExecutableTx) + const { safeTx, safeTxError } = useContext(SafeTxContext) + const isCorrectNonce = useValidateNonce(safeTx) // If checkbox is checked and the transaction is executable, execute it, otherwise sign it - const willExecute = (onlyExecute || shouldExecute) && canExecute - - // We default to relay, but the option is only shown if we canRelay - const [executionMethod, setExecutionMethod] = useState(ExecutionMethod.RELAY) - - // SC wallets can relay fully signed transactions - const [walletCanRelay] = useWalletCanRelay(tx) - - // The transaction can/will be relayed - const canRelay = hasRemainingRelays(relays) && !!walletCanRelay && willExecute - const willRelay = canRelay && executionMethod === ExecutionMethod.RELAY - - // Synchronize the tx with the safeTx - useEffect(() => setTx(safeTx), [safeTx]) - - // Estimate gas limit - const { gasLimit, gasLimitError, gasLimitLoading } = useGasLimit(willExecute ? tx : undefined) - - const [advancedParams, setAdvancedParams] = useAdvancedParams({ - nonce: tx?.data.nonce, - gasLimit, - safeTxGas: tx?.data.safeTxGas, - }) - - // Check if transaction will fail - const { executionValidationError, isValidExecutionLoading } = useIsValidExecution( - willExecute ? tx : undefined, - advancedParams.gasLimit, - ) - - // Estimating gas - const isEstimating = willExecute && gasLimitLoading - // Nonce cannot be edited if the tx is already proposed, or signed, or it's a rejection - const nonceReadonly = !isCreation || !!tx?.signatures.size || isRejection - - // Sign transaction - const onSign = async (): Promise<string | undefined> => { - return await signTx(tx, txId, origin) - } - - // Execute transaction - const onExecute = async (): Promise<string | undefined> => { - const txOptions = getTxOptions(advancedParams, currentChain) - return await executeTx(txOptions, tx, txId, origin, willRelay) - } - - // On modal submit - const handleSubmit = async (e: SyntheticEvent) => { - e.preventDefault() - setIsSubmittable(false) - setSubmitError(undefined) - - try { - await (willExecute ? onExecute() : onSign()) - } catch (err) { - logError(Errors._804, (err as Error).message) - setIsSubmittable(true) - setSubmitError(err as Error) - return - } - - onSubmit() - } - - // On advanced params submit (nonce, gas limit, price, etc), recreate the transaction - const onAdvancedSubmit = async (data: AdvancedParameters) => { - // If nonce was edited, create a new tx with that nonce - if (tx && (data.nonce !== tx.data.nonce || data.safeTxGas !== tx.data.safeTxGas)) { - try { - setTx(await createTx({ ...tx.data, safeTxGas: data.safeTxGas }, data.nonce)) - } catch (err) { - logError(Errors._103, (err as Error).message) - return - } - } - - setAdvancedParams(data) - } - - const handleExecuteCheckboxChange = (checked: boolean) => { - setShouldExecute(checked) - dispatch(setTransactionExecution(checked)) - } - - const cannotPropose = !isOwner && !onlyExecute // Can't sign or create a tx if not an owner - const submitDisabled = - !isSubmittable || - isEstimating || - !tx || - disableSubmit || - cannotPropose || - isValidExecutionLoading || - (willExecute && isExecutionLoop) - - const error = props.error || (willExecute ? gasLimitError || executionValidationError : undefined) + const canExecute = isCorrectNonce && (props.isExecutable || isNewExecutableTx) + const willExecute = (props.onlyExecute || shouldExecute) && canExecute return ( - <form onSubmit={handleSubmit}> - <DialogContent> - {children} + <> + <TxCard> + {props.children} - <TransactionSecurityProvider safeTx={safeTx}> - <> - <RedefineBalanceChanges /> - <DecodedTx tx={tx} txId={txId} /> + <DecodedTx tx={safeTx} txId={props.txId} /> - {canExecute && ( - <ExecuteCheckbox - checked={shouldExecute || onlyExecute} - onChange={handleExecuteCheckboxChange} - disabled={onlyExecute} - /> - )} + <RedefineBalanceChanges /> + </TxCard> - <AdvancedParams - params={advancedParams} - recommendedGasLimit={gasLimit} - recommendedNonce={safeTx?.data.nonce} - willExecute={willExecute} - nonceReadonly={nonceReadonly} - onFormSubmit={onAdvancedSubmit} - gasLimitError={gasLimitError} - willRelay={willRelay} - /> + <TxCard> + <TxChecks /> + </TxCard> - {canRelay && ( - <Box - sx={{ - '& > div': { - marginTop: '-1px', - borderTopLeftRadius: 0, - borderTopRightRadius: 0, - }, - }} - > - <ExecutionMethodSelector - executionMethod={executionMethod} - setExecutionMethod={setExecutionMethod} - relays={relays} - /> - </Box> - )} + <TxCard> + <ConfirmationTitle + variant={willExecute ? ConfirmationTitleTypes.execute : ConfirmationTitleTypes.sign} + isCreation={isCreation} + /> - <TxSimulation - gasLimit={advancedParams.gasLimit?.toNumber()} - transactions={tx} - canExecute={canExecute} - disabled={submitDisabled} - /> + {safeTxError && ( + <ErrorMessage error={safeTxError}> + This transaction will most likely fail. To save gas costs, avoid confirming the transaction. + </ErrorMessage> + )} - <RedefineScanResult /> + {canExecute && !props.onlyExecute && <ExecuteCheckbox onChange={setShouldExecute} />} - {/* Warning message and switch button */} - <WrongChainWarning /> + <WrongChainWarning /> - {/* Error messages */} - {isSubmittable && cannotPropose ? ( - <ErrorMessage> - You are currently not an owner of this Safe Account and won't be able to submit this transaction. - </ErrorMessage> - ) : willExecute && isExecutionLoop ? ( - <ErrorMessage> - Cannot execute a transaction from the Safe Account itself, please connect a different account. - </ErrorMessage> - ) : error ? ( - <ErrorMessage error={error}> - This transaction will most likely fail.{' '} - {isNewExecutableTx - ? 'To save gas costs, avoid creating the transaction.' - : 'To save gas costs, reject this transaction.'} - </ErrorMessage> - ) : submitError ? ( - <ErrorMessage error={submitError}>Error submitting the transaction. Please try again.</ErrorMessage> - ) : ( - willExecute && <UnknownContractError /> - )} + <UnknownContractError /> - {/* Info text */} - <Typography variant="body2" color="border.main" textAlign="center" mt={3}> - You're about to {txId ? '' : 'create and '} - {willExecute ? 'execute' : 'sign'} a transaction and will need to confirm it with your currently connected - wallet. - </Typography> + <RiskConfirmationError /> - <SubmitButton isEstimating={isEstimating} submitDisabled={submitDisabled} willExecute={willExecute} /> - </> - </TransactionSecurityProvider> - </DialogContent> - </form> + {willExecute ? <ExecuteForm {...props} safeTx={safeTx} /> : <SignForm {...props} safeTx={safeTx} />} + </TxCard> + </> ) } diff --git a/src/components/tx/SignOrExecuteForm/styles.module.css b/src/components/tx/SignOrExecuteForm/styles.module.css new file mode 100644 index 0000000000..15974e55a6 --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/styles.module.css @@ -0,0 +1,58 @@ +.wrapper { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-1); +} + +.icon { + width: 34px; + height: 34px; + border-radius: 6px; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; +} + +.sign { + background-color: var(--color-info-background); +} + +.sign svg { + color: var(--color-info-dark); +} + +.execute { + background-color: var(--color-secondary-background); +} + +.execute svg { + color: var(--color-secondary-dark); +} + +.params { + margin-bottom: var(--space-2); +} + +.noBottomBorderRadius :global(.MuiPaper-root) { + border-bottom-left-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.noTopBorder > div { + margin-top: -1px; + border-top-left-radius: 0 !important; + border-top-right-radius: 0 !important; +} + +.mobileTxCheckMessages, +.mobileTxCheckMessages:empty { + display: none; +} + +@media (max-width: 899.95px) { + .mobileTxCheckMessages { + display: block; + } +} diff --git a/src/components/tx/SpendingLimitRow/index.tsx b/src/components/tx/SpendingLimitRow/index.tsx index c0840f346a..6a4a1e9279 100644 --- a/src/components/tx/SpendingLimitRow/index.tsx +++ b/src/components/tx/SpendingLimitRow/index.tsx @@ -1,10 +1,19 @@ -import { FormControl, FormControlLabel, Radio, RadioGroup, Typography } from '@mui/material' +import { FormControl, FormControlLabel, InputLabel, Radio, RadioGroup, SvgIcon, Tooltip } from '@mui/material' import { Controller, useFormContext } from 'react-hook-form' import type { BigNumber } from '@ethersproject/bignumber' +import classNames from 'classnames' import { safeFormatUnits } from '@/utils/formatters' import type { TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { SendAssetsField, SendTxType } from '@/components/tx/modals/TokenTransferModal/SendAssetsForm' +import { TokenTransferFields, TokenTransferType } from '@/components/tx-flow/flows/TokenTransfer' import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' +import InfoIcon from '@/public/images/notifications/info.svg' +import ExternalLink from '@/components/common/ExternalLink' +import { HelpCenterArticle } from '@/config/constants' + +import css from './styles.module.css' +import { TokenAmountFields } from '@/components/common/TokenAmountInput' +import { useContext } from 'react' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' const SpendingLimitRow = ({ availableAmount, @@ -15,49 +24,121 @@ const SpendingLimitRow = ({ }) => { const { control, trigger } = useFormContext() const isOnlySpendLimitBeneficiary = useIsOnlySpendingLimitBeneficiary() + const { setNonceNeeded } = useContext(SafeTxContext) const formattedAmount = safeFormatUnits(availableAmount, selectedToken?.decimals) return ( - <> - <Typography>Send as</Typography> - <FormControl> - <Controller - rules={{ required: true }} - control={control} - name={SendAssetsField.type} - render={({ field: { onChange, ...field } }) => ( - <RadioGroup - onChange={(e) => { - onChange(e) + <FormControl> + <InputLabel shrink required sx={{ backgroundColor: 'background.paper', px: '6px', mx: '-6px' }}> + Send as + </InputLabel> + <Controller + rules={{ required: true }} + control={control} + name={TokenTransferFields.type} + render={({ field: { onChange, ...field } }) => ( + <RadioGroup + row + onChange={(e) => { + onChange(e) + + setNonceNeeded(e.target.value === TokenTransferType.multiSig) - // Validate only after the field is changed - setTimeout(() => { - trigger(SendAssetsField.amount) - }, 10) - }} - {...field} - defaultValue={SendTxType.multiSig} - > - {!isOnlySpendLimitBeneficiary && ( - <FormControlLabel - value={SendTxType.multiSig} - label="Multisig transaction" - control={<Radio />} - componentsProps={{ typography: { variant: 'body2' } }} - /> - )} + // Validate only after the field is changed + setTimeout(() => { + trigger(TokenAmountFields.amount) + }, 10) + }} + {...field} + defaultValue={TokenTransferType.multiSig} + className={css.group} + > + {!isOnlySpendLimitBeneficiary && ( <FormControlLabel - value={SendTxType.spendingLimit} - label={`Spending limit transaction (${formattedAmount} ${selectedToken?.symbol})`} + value={TokenTransferType.multiSig} + label={ + <> + Standard transaction + <Tooltip + title={ + <> + A standard transaction requires the signatures of other owners before the specified funds can + be transferred.  + <ExternalLink + href={HelpCenterArticle.SPENDING_LIMITS} + title="Learn more about spending limits" + > + Learn more about spending limits + </ExternalLink> + . + </> + } + arrow + placement="top" + > + <span> + <SvgIcon + component={InfoIcon} + inheritViewBox + color="border" + fontSize="small" + sx={{ + verticalAlign: 'middle', + ml: 0.5, + }} + /> + </span> + </Tooltip> + </> + } control={<Radio />} componentsProps={{ typography: { variant: 'body2' } }} + className={css.label} /> - </RadioGroup> - )} - /> - </FormControl> - </> + )} + <FormControlLabel + value={TokenTransferType.spendingLimit} + label={ + <> + Spending limit <b>{`(${formattedAmount} ${selectedToken?.symbol})`}</b> + <Tooltip + title={ + <> + A spending limit transaction allows you to transfer the specified funds without the need to + collect the signatures of other owners.  + <ExternalLink href={HelpCenterArticle.SPENDING_LIMITS} title="Learn more about spending limits"> + Learn more about spending limits + </ExternalLink> + . + </> + } + arrow + placement="top" + > + <span> + <SvgIcon + component={InfoIcon} + inheritViewBox + color="border" + fontSize="small" + sx={{ + verticalAlign: 'middle', + ml: 0.5, + }} + /> + </span> + </Tooltip> + </> + } + control={<Radio />} + componentsProps={{ typography: { variant: 'body2' } }} + className={classNames(css.label, { [css.spendingLimit]: !isOnlySpendLimitBeneficiary })} + /> + </RadioGroup> + )} + /> + </FormControl> ) } diff --git a/src/components/tx/SpendingLimitRow/styles.module.css b/src/components/tx/SpendingLimitRow/styles.module.css new file mode 100644 index 0000000000..cd57976d7d --- /dev/null +++ b/src/components/tx/SpendingLimitRow/styles.module.css @@ -0,0 +1,15 @@ +.group { + border: 1px solid var(--color-border-main); + border-radius: 4px; + display: grid; + grid-template-columns: 50% 50%; +} + +.label { + margin: 0; + padding: 12px 3px; +} + +.spendingLimit { + border-left: 1px solid var(--color-border-main); +} diff --git a/src/components/tx/SponsoredBy/index.tsx b/src/components/tx/SponsoredBy/index.tsx index 95aab41f32..6ae946c88e 100644 --- a/src/components/tx/SponsoredBy/index.tsx +++ b/src/components/tx/SponsoredBy/index.tsx @@ -17,8 +17,8 @@ const SponsoredBy = ({ relays, tooltip }: { relays: RelayResponse; tooltip?: str return ( <Box className={css.sponsoredBy}> <SvgIcon component={GasStationIcon} inheritViewBox className={css.icon} /> - <Stack direction="column"> - <Stack direction="row" spacing={0.5} alignItems="center" mb={1}> + <div> + <Stack direction="row" spacing={0.5} alignItems="center"> <Typography variant="body2" fontWeight={700} letterSpacing="0.1px"> Sponsored by </Typography> @@ -40,15 +40,14 @@ const SponsoredBy = ({ relays, tooltip }: { relays: RelayResponse; tooltip?: str </Tooltip> ) : null} </Stack> - <div> - <Typography color="primary.light"> - Transactions per hour:{' '} - <Box component="span" sx={{ fontWeight: '700', color: 'text.primary' }}> - {relays.remaining} of {relays.limit} - </Box> - </Typography> - </div> - </Stack> + + <Typography variant="body2" color="primary.light"> + Transactions per hour:{' '} + <Box component="span" sx={{ fontWeight: '700', color: 'text.primary' }}> + {relays.remaining} of {relays.limit} + </Box> + </Typography> + </div> </Box> ) } diff --git a/src/components/tx/SuccessMessage/index.tsx b/src/components/tx/SuccessMessage/index.tsx index ee11f65e92..9f3ce22916 100644 --- a/src/components/tx/SuccessMessage/index.tsx +++ b/src/components/tx/SuccessMessage/index.tsx @@ -1,20 +1,18 @@ import { type ReactElement, type ReactNode } from 'react' import { Typography, SvgIcon } from '@mui/material' import classNames from 'classnames' -import SuccessIcon from '@/public/images/notifications/success.svg' +import CheckIcon from '@/public/images/common/check.svg' import css from './styles.module.css' const SuccessMessage = ({ children, className }: { children: ReactNode; className?: string }): ReactElement => { return ( <div className={classNames(css.container, className)}> <div className={css.message}> - <SvgIcon component={SuccessIcon} color="success" inheritViewBox fontSize="small" /> + <SvgIcon component={CheckIcon} color="success" inheritViewBox fontSize="small" /> - <div> - <Typography variant="body2" component="span"> - {children} - </Typography> - </div> + <Typography variant="body2" width="100%"> + {children} + </Typography> </div> </div> ) diff --git a/src/components/tx/SuccessMessage/styles.module.css b/src/components/tx/SuccessMessage/styles.module.css index 7ae0ce6c58..418cdcdea6 100644 --- a/src/components/tx/SuccessMessage/styles.module.css +++ b/src/components/tx/SuccessMessage/styles.module.css @@ -1,7 +1,6 @@ .container { background-color: var(--color-success-background); padding: var(--space-2); - margin: var(--space-2) 0; border-radius: 4px; } diff --git a/src/components/tx/TxSimulation/SimulationResult.tsx b/src/components/tx/TxSimulation/SimulationResult.tsx deleted file mode 100644 index f4bd9ec4ff..0000000000 --- a/src/components/tx/TxSimulation/SimulationResult.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Alert, AlertTitle, Typography } from '@mui/material' -import type { ReactElement } from 'react' - -import type { TenderlySimulation } from '@/components/tx/TxSimulation/types' -import { FETCH_STATUS } from '@/components/tx/TxSimulation/types' - -import css from './styles.module.css' -import ExternalLink from '@/components/common/ExternalLink' - -type SimulationResultProps = { - simulationRequestStatus: string - simulation?: TenderlySimulation - simulationLink: string - requestError?: string - onClose: () => void -} - -const getCallTraceErrors = (simulation?: TenderlySimulation) => { - if (!simulation) { - return [] - } - - return simulation.transaction.call_trace.filter((call) => call.error) -} - -export const SimulationResult = ({ - simulationRequestStatus, - simulation, - simulationLink, - requestError, - onClose, -}: SimulationResultProps): ReactElement | null => { - const isSimulationFinished = - simulationRequestStatus === FETCH_STATUS.SUCCESS || simulationRequestStatus === FETCH_STATUS.ERROR - - // Loading - if (!isSimulationFinished) { - return null - } - - const isSuccess = simulation?.simulation.status - - // Safe can emit failure event even though Tenderly simulation succeeds - const isCallTraceError = isSuccess && getCallTraceErrors(simulation).length > 0 - - // Error - if (requestError || !isSuccess || isCallTraceError) { - return ( - <Alert severity="error" onClose={onClose} className={css.result}> - <AlertTitle color="error"> - <b>Failed</b> - </AlertTitle> - - {requestError ? ( - <Typography color="error"> - An unexpected error occurred during simulation: <b>{requestError}</b>. - </Typography> - ) : ( - <Typography> - {isCallTraceError ? ( - <>The transaction failed during the simulation.</> - ) : ( - <> - The transaction failed during the simulation throwing error{' '} - <b>{simulation?.transaction.error_message}</b> in the contract at{' '} - <b>{simulation?.transaction.error_info?.address}</b>. - </> - )}{' '} - Full simulation report is available <ExternalLink href={simulationLink}>on Tenderly</ExternalLink>. - </Typography> - )} - </Alert> - ) - } - - // Success - return ( - <Alert severity="success" onClose={onClose} className={css.result}> - <AlertTitle color="success"> - <b>Success</b> - </AlertTitle> - - <Typography> - The transaction was successfully simulated. Full simulation report is available{' '} - <ExternalLink href={simulationLink}>on Tenderly</ExternalLink>. - </Typography> - </Alert> - ) -} diff --git a/src/components/tx/TxSimulation/index.tsx b/src/components/tx/TxSimulation/index.tsx deleted file mode 100644 index de4c78f3cf..0000000000 --- a/src/components/tx/TxSimulation/index.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { AccordionSummary, Accordion, Button, Typography, CircularProgress, Skeleton } from '@mui/material' -import type { ReactElement } from 'react' -import { useEffect } from 'react' - -import Track from '@/components/common/Track' -import { useCurrentChain } from '@/hooks/useChains' -import useSafeInfo from '@/hooks/useSafeInfo' -import useWallet from '@/hooks/wallets/useWallet' -import { MODALS_EVENTS } from '@/services/analytics' -import { SimulationResult } from '@/components/tx/TxSimulation/SimulationResult' -import { FETCH_STATUS } from '@/components/tx/TxSimulation/types' -import { useSimulation } from '@/components/tx/TxSimulation/useSimulation' -import { isTxSimulationEnabled } from '@/components/tx/TxSimulation/utils' -import type { SimulationTxParams } from '@/components/tx/TxSimulation/utils' - -import css from './styles.module.css' -import classNames from 'classnames' - -export type TxSimulationProps = { - transactions?: SimulationTxParams['transactions'] - gasLimit?: number - canExecute: boolean - disabled: boolean -} - -const TxSimulationBlock = ({ transactions, canExecute, disabled, gasLimit }: TxSimulationProps): ReactElement => { - const { safe } = useSafeInfo() - const wallet = useWallet() - - const { simulateTransaction, simulation, simulationRequestStatus, simulationLink, requestError, resetSimulation } = - useSimulation() - - const handleSimulation = async () => { - if (!wallet) { - return - } - - simulateTransaction({ - safe, - executionOwner: wallet.address, - transactions, - canExecute, - gasLimit, - } as SimulationTxParams) - } - - // Reset simulation if gas limit changes - useEffect(() => { - resetSimulation() - }, [gasLimit, resetSimulation]) - - const isSimulationFinished = - simulationRequestStatus === FETCH_STATUS.ERROR || simulationRequestStatus === FETCH_STATUS.SUCCESS - const isSimulationLoading = simulationRequestStatus === FETCH_STATUS.LOADING - - return ( - <Accordion expanded={isSimulationFinished} elevation={0} sx={{ mt: '16px !important' }}> - {!isSimulationFinished ? ( - <AccordionSummary className={css.simulateAccordion}> - <Typography>Transaction validity</Typography> - <Track {...MODALS_EVENTS.SIMULATE_TX}> - <Button - variant="text" - size="small" - disabled={disabled || isSimulationLoading} - color="primary" - onClick={handleSimulation} - > - {isSimulationLoading && <CircularProgress size={14} />} - <span className={classNames(css.loadingText, isSimulationLoading)}> - {isSimulationLoading ? 'Simulating...' : 'Simulate'} - </span> - </Button> - </Track> - </AccordionSummary> - ) : ( - <SimulationResult - onClose={resetSimulation} - simulation={simulation} - simulationRequestStatus={simulationRequestStatus} - simulationLink={simulationLink} - requestError={requestError} - /> - )} - </Accordion> - ) -} - -export const TxSimulation = (props: TxSimulationProps): ReactElement | null => { - const chain = useCurrentChain() - if (!chain || !isTxSimulationEnabled(chain)) { - return null - } - - if (!props.transactions) { - return ( - <div className={css.skeletonWrapper}> - <Skeleton variant="rectangular" height={58} /> - </div> - ) - } - - return <TxSimulationBlock {...props} /> -} diff --git a/src/components/tx/TxSimulation/styles.module.css b/src/components/tx/TxSimulation/styles.module.css deleted file mode 100644 index 6842dfa30f..0000000000 --- a/src/components/tx/TxSimulation/styles.module.css +++ /dev/null @@ -1,18 +0,0 @@ -.simulateAccordion :global .MuiAccordionSummary-content { - justify-content: space-between; - align-items: center; -} - -.loadingText { - margin-left: var(--space-1); -} - -.result { - border: none; -} - -.skeletonWrapper { - border-radius: 8px; - overflow: hidden; - margin-top: var(--space-2); -} diff --git a/src/components/tx/modals/BatchExecuteModal/DecodedTxs.tsx b/src/components/tx/modals/BatchExecuteModal/DecodedTxs.tsx deleted file mode 100644 index 6afab097dc..0000000000 --- a/src/components/tx/modals/BatchExecuteModal/DecodedTxs.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { DataDecoded, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' -import { Box } from '@mui/material' -import useSafeInfo from '@/hooks/useSafeInfo' -import extractTxInfo from '@/services/tx/extractTxInfo' -import { isCustomTxInfo, isNativeTokenTransfer, isTransferTxInfo } from '@/utils/transaction-guards' -import SingleTxDecoded from '@/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded' - -const DecodedTxs = ({ txs }: { txs: TransactionDetails[] | undefined }) => { - const { safeAddress } = useSafeInfo() - - if (!txs) return null - - return ( - <Box mt={1} display="flex" flexDirection="column" gap={1}> - {txs.map((transaction, idx) => { - if (!transaction.txData) return null - - const { txParams } = extractTxInfo(transaction, safeAddress) - - let decodedDataParams: DataDecoded = { - method: '', - parameters: undefined, - } - - if (isCustomTxInfo(transaction.txInfo) && transaction.txInfo.isCancellation) { - decodedDataParams.method = 'On-chain rejection' - } - - if (isTransferTxInfo(transaction.txInfo) && isNativeTokenTransfer(transaction.txInfo.transferInfo)) { - decodedDataParams.method = 'transfer' - } - - const dataDecoded = transaction.txData.dataDecoded || decodedDataParams - - return ( - <SingleTxDecoded - key={transaction.txId} - tx={{ - dataDecoded, - data: txParams.data, - value: txParams.value, - to: txParams.to, - operation: 0, - }} - txData={transaction.txData} - actionTitle={`Action ${idx + 1}`} - showDelegateCallWarning={false} - /> - ) - })} - </Box> - ) -} - -export default DecodedTxs diff --git a/src/components/tx/modals/BatchExecuteModal/index.tsx b/src/components/tx/modals/BatchExecuteModal/index.tsx deleted file mode 100644 index 8de5f2bb8e..0000000000 --- a/src/components/tx/modals/BatchExecuteModal/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react' - -import type { TxModalProps } from '@/components/tx/TxModal' -import TxModal from '@/components/tx/TxModal' -import type { Transaction } from '@safe-global/safe-gateway-typescript-sdk' -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import ReviewBatchExecute from '@/components/tx/modals/BatchExecuteModal/ReviewBatchExecute' - -export type BatchExecuteData = { - txs: Transaction[] -} - -const BatchExecuteSteps: TxStepperProps['steps'] = [ - { - label: 'Execute batch', - render: (data, onSubmit) => <ReviewBatchExecute data={data as BatchExecuteData} onSubmit={onSubmit} />, - }, -] - -const BatchExecuteModal = (props: Omit<TxModalProps, 'steps'>) => { - return <TxModal {...props} steps={BatchExecuteSteps} /> -} - -export default BatchExecuteModal diff --git a/src/components/tx/modals/ConfirmTxModal/index.tsx b/src/components/tx/modals/ConfirmTxModal/index.tsx deleted file mode 100644 index 8559ce850b..0000000000 --- a/src/components/tx/modals/ConfirmTxModal/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react' - -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import type { TxModalProps } from '@/components/tx/TxModal' -import TxModal from '@/components/tx/TxModal' -import ConfirmProposedTx from '@/components/tx/modals/ConfirmTxModal/ConfirmProposedTx' -import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' - -export const ConfirmTxSteps: TxStepperProps['steps'] = [ - { - label: 'Confirm transaction', - render: (data, onSubmit) => <ConfirmProposedTx txSummary={data as TransactionSummary} onSubmit={onSubmit} />, - }, -] - -const ConfirmTxModal = (props: Omit<TxModalProps, 'steps'>) => { - return <TxModal {...props} steps={ConfirmTxSteps} /> -} - -export default ConfirmTxModal diff --git a/src/components/tx/modals/ExecuteTxModal/index.tsx b/src/components/tx/modals/ExecuteTxModal/index.tsx deleted file mode 100644 index 7591b99e3b..0000000000 --- a/src/components/tx/modals/ExecuteTxModal/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react' - -import type { TxModalProps } from '@/components/tx/TxModal' -import TxModal from '@/components/tx/TxModal' -import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import ConfirmProposedTx from '@/components/tx/modals/ConfirmTxModal/ConfirmProposedTx' - -export const ExecuteTxSteps: TxStepperProps['steps'] = [ - { - label: 'Execute transaction', - render: (data, onSubmit) => <ConfirmProposedTx txSummary={data as TransactionSummary} onSubmit={onSubmit} />, - }, -] - -const ExecuteTxModal = (props: Omit<TxModalProps, 'steps'>) => { - return <TxModal {...props} steps={ExecuteTxSteps} /> -} - -export default ExecuteTxModal diff --git a/src/components/tx/modals/NewTxModal/CreationModal.tsx b/src/components/tx/modals/NewTxModal/CreationModal.tsx deleted file mode 100644 index d9a597c778..0000000000 --- a/src/components/tx/modals/NewTxModal/CreationModal.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Box, DialogContent } from '@mui/material' -import Link from 'next/link' - -import ModalDialog from '@/components/common/ModalDialog' -import { useTxBuilderApp } from '@/hooks/safe-apps/useTxBuilderApp' -import TxButton, { SendNFTsButton, SendTokensButton } from './TxButton' -import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' - -const CreationModal = ({ - open, - onClose, - onTokenModalOpen, - onNFTModalOpen, - onContractInteraction, - shouldShowTxBuilder, -}: { - open: boolean - onClose: () => void - onTokenModalOpen: () => void - onNFTModalOpen?: () => void - onContractInteraction: () => void - shouldShowTxBuilder: boolean -}) => { - const isOnlySpendingLimitBeneficiary = useIsOnlySpendingLimitBeneficiary() - const txBuilder = useTxBuilderApp() - - return ( - <ModalDialog open={open} dialogTitle="New transaction" onClose={onClose}> - <DialogContent> - <Box display="flex" flexDirection="column" alignItems="center" gap={2} pt={7} pb={4} width={240} m="auto"> - <SendTokensButton onClick={onTokenModalOpen} /> - - {!isOnlySpendingLimitBeneficiary && ( - <> - {onNFTModalOpen && <SendNFTsButton onClick={onNFTModalOpen} />} - - {txBuilder && txBuilder.app && shouldShowTxBuilder && ( - <Link href={txBuilder.link} passHref> - <a style={{ width: '100%' }}> - <TxButton - startIcon={<img src={txBuilder.app.iconUrl} height={20} width="auto" alt={txBuilder.app.name} />} - variant="outlined" - onClick={onContractInteraction} - > - Contract interaction - </TxButton> - </a> - </Link> - )} - </> - )} - </Box> - </DialogContent> - </ModalDialog> - ) -} - -export default CreationModal diff --git a/src/components/tx/modals/NewTxModal/ReplacementModal.tsx b/src/components/tx/modals/NewTxModal/ReplacementModal.tsx deleted file mode 100644 index beb19b0977..0000000000 --- a/src/components/tx/modals/NewTxModal/ReplacementModal.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { - Button, - DialogContent, - SvgIcon, - Tooltip, - Typography, - Stepper, - Step, - StepLabel, - DialogActions, - Grid, -} from '@mui/material' - -import ModalDialog from '@/components/common/ModalDialog' -import InfoIcon from '@/public/images/notifications/info.svg' -import RocketIcon from '@/public/images/transactions/rocket.svg' -import CheckIcon from '@mui/icons-material/Check' -import DeleteIcon from '@/public/images/common/delete.svg' -import { SendTokensButton } from './TxButton' -import { useQueuedTxByNonce } from '@/hooks/useTxQueue' -import { isCustomTxInfo } from '@/utils/transaction-guards' - -import css from './styles.module.css' - -const wrapIcon = (icon: React.ReactNode) => <div className={css.circle}>{icon}</div> - -const steps = [ - { - label: 'Create new transaction with same nonce', - icon: <div className={css.redCircle} />, - }, - { - label: 'Collect confirmations from owners', - icon: wrapIcon(<CheckIcon fontSize="small" color="border" />), - }, - { - label: 'Execute replacement transaction', - icon: wrapIcon(<SvgIcon component={RocketIcon} inheritViewBox fontSize="small" color="border" />), - }, - { - label: 'Initial transaction is replaced', - icon: wrapIcon(<SvgIcon component={DeleteIcon} inheritViewBox fontSize="small" color="border" />), - }, -] - -const btnWidth = { - width: { - xs: 240, - sm: '100%', - }, -} - -const ReplacementModal = ({ - open, - txNonce, - onClose, - onTokenModalOpen, - onRejectModalOpen, -}: { - open: boolean - txNonce: number - onClose: () => void - onTokenModalOpen: () => void - onRejectModalOpen: () => void -}) => { - const queuedTxsByNonce = useQueuedTxByNonce(txNonce) - const canCancel = !queuedTxsByNonce?.some( - (item) => isCustomTxInfo(item.transaction.txInfo) && item.transaction.txInfo.isCancellation, - ) - - return ( - <ModalDialog open={open} dialogTitle={`Replace transaction with nonce ${txNonce}`} onClose={onClose}> - <DialogContent className={css.container}> - <Typography variant="h5" mb={1} textAlign="center"> - Need to replace or discard this transaction? - </Typography> - <Typography variant="body1" textAlign="center"> - A signed transaction cannot be removed but it can be replaced with a new transaction with the same nonce. - </Typography> - <Stepper alternativeLabel className={css.stepper}> - {steps.map(({ label }) => ( - <Step key={label}> - <StepLabel StepIconComponent={({ icon }) => steps[Number(icon) - 1].icon}> - <Typography variant="body1" fontWeight={700}> - {label} - </Typography> - </StepLabel> - </Step> - ))} - </Stepper> - </DialogContent> - <DialogActions className={css.container}> - <Grid container alignItems="center" justifyContent="center" flexDirection="row"> - <Grid item xs={12}> - <Typography variant="body2" textAlign="center" fontWeight={700} mb={3}> - Select how you would like to replace this transaction - </Typography> - </Grid> - <Grid item container justifyContent="center" alignItems="center" gap={1} xs={12} sm flexDirection="row"> - <SendTokensButton onClick={onTokenModalOpen} sx={btnWidth} /> - </Grid> - <Grid item> - <Typography variant="body2" className={css.or}> - or - </Typography> - </Grid> - <Grid - item - container - xs={12} - sm - justifyContent={{ - xs: 'center', - sm: 'flex-start', - }} - alignItems="center" - textAlign="center" - flexDirection="row" - > - <Tooltip - arrow - placement="top" - title={canCancel ? '' : `Transaction with nonce ${txNonce} already has a reject transaction`} - > - <span style={{ width: '100%' }}> - <Button - onClick={onRejectModalOpen} - variant="outlined" - fullWidth - sx={{ mb: 1, ...btnWidth }} - disabled={!canCancel} - > - Reject transaction - </Button> - </span> - </Tooltip> - - <div> - <Typography variant="caption" display="flex" alignItems="center"> - How does it work?{' '} - <Tooltip - title={`An on-chain rejection doesn't send any funds. Executing an on-chain rejection will replace all currently awaiting transactions with nonce ${txNonce}.`} - arrow - > - <span> - <SvgIcon - component={InfoIcon} - inheritViewBox - fontSize="small" - color="border" - sx={{ verticalAlign: 'middle', ml: 0.5 }} - /> - </span> - </Tooltip> - </Typography> - </div> - </Grid> - </Grid> - </DialogActions> - </ModalDialog> - ) -} - -export default ReplacementModal diff --git a/src/components/tx/modals/NewTxModal/TxButton.tsx b/src/components/tx/modals/NewTxModal/TxButton.tsx deleted file mode 100644 index 32452c4385..0000000000 --- a/src/components/tx/modals/NewTxModal/TxButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Button, SvgIcon } from '@mui/material' -import type { ButtonProps } from '@mui/material' - -import AssetsIcon from '@/public/images/sidebar/assets.svg' -import NftIcon from '@/public/images/common/nft.svg' - -const TxButton = ({ sx, ...props }: ButtonProps) => ( - <Button variant="contained" sx={{ '& svg path': { fill: 'currentColor' }, ...sx }} fullWidth {...props} /> -) - -export const SendTokensButton = ({ onClick, ...props }: ButtonProps) => ( - <TxButton onClick={onClick} startIcon={<SvgIcon component={AssetsIcon} inheritViewBox />} {...props}> - Send tokens - </TxButton> -) - -export const SendNFTsButton = ({ onClick, ...props }: ButtonProps) => ( - <TxButton onClick={onClick} startIcon={<SvgIcon component={NftIcon} inheritViewBox />} {...props}> - Send NFTs - </TxButton> -) - -export default TxButton diff --git a/src/components/tx/modals/NewTxModal/index.tsx b/src/components/tx/modals/NewTxModal/index.tsx deleted file mode 100644 index c41c6a2f54..0000000000 --- a/src/components/tx/modals/NewTxModal/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useState } from 'react' -import type { ReactElement } from 'react' -import { useRouter } from 'next/router' - -import TokenTransferModal from '../TokenTransferModal' -import RejectTxModal from '../RejectTxModal' -import { trackEvent, MODALS_EVENTS } from '@/services/analytics' -import { SendAssetsField } from '../TokenTransferModal/SendAssetsForm' -import CreationModal from './CreationModal' -import ReplacementModal from './ReplacementModal' -import { AppRoutes } from '@/config/routes' - -const NewTxModal = ({ - onClose, - recipient = '', - txNonce, -}: { - onClose: () => void - recipient?: string - txNonce?: number -}): ReactElement => { - const router = useRouter() - const [tokenModalOpen, setTokenModalOpen] = useState<boolean>(false) - const [rejectModalOpen, setRejectModalOpen] = useState<boolean>(false) - const isReplacement = txNonce !== undefined - const showNftButton = router.pathname !== AppRoutes.balances.nfts - - // These cannot be Track components as they intefere with styling - const onTokenModalOpen = () => { - trackEvent(MODALS_EVENTS.SEND_FUNDS) - setTokenModalOpen(true) - } - - const onNFTModalOpen = () => { - trackEvent(MODALS_EVENTS.SEND_COLLECTIBLE) - router.push({ - pathname: AppRoutes.balances.nfts, - query: { safe: router.query.safe }, - }) - onClose() - } - - const onRejectModalOpen = () => { - trackEvent(MODALS_EVENTS.REJECT_TX) - setRejectModalOpen(true) - } - - const onContractInteraction = () => { - trackEvent(MODALS_EVENTS.CONTRACT_INTERACTION) - onClose() - } - - const sharedProps = { - open: !tokenModalOpen, - onClose, - onTokenModalOpen, - } - - return ( - <> - {isReplacement ? ( - <ReplacementModal txNonce={txNonce} onRejectModalOpen={onRejectModalOpen} {...sharedProps} /> - ) : ( - <CreationModal - shouldShowTxBuilder={!recipient} - onNFTModalOpen={showNftButton ? onNFTModalOpen : undefined} - onContractInteraction={onContractInteraction} - {...sharedProps} - /> - )} - - {tokenModalOpen && ( - <TokenTransferModal - onClose={onClose} - initialData={[{ [SendAssetsField.recipient]: recipient, disableSpendingLimit: isReplacement }, { txNonce }]} - /> - )} - - {rejectModalOpen && typeof txNonce === 'number' ? ( - <RejectTxModal onClose={onClose} initialData={[txNonce]} /> - ) : null} - </> - ) -} - -export default NewTxModal diff --git a/src/components/tx/modals/NftBatchModal/ReviewNftBatch.tsx b/src/components/tx/modals/NftBatchModal/ReviewNftBatch.tsx deleted file mode 100644 index b9fd391d0a..0000000000 --- a/src/components/tx/modals/NftBatchModal/ReviewNftBatch.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { type ReactElement } from 'react' -import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' -import SendFromBlock from '../../SendFromBlock' -import SignOrExecuteForm from '../../SignOrExecuteForm' -import SendToBlock from '@/components/tx/SendToBlock' -import useAsync from '@/hooks/useAsync' -import { createNftTransferParams } from '@/services/tx/tokenTransferParams' -import { type NftTransferParams } from '.' -import useSafeAddress from '@/hooks/useSafeAddress' -import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' - -type ReviewNftBatchProps = { - params: NftTransferParams - onSubmit: () => void -} - -const ReviewNftBatch = ({ params, onSubmit }: ReviewNftBatchProps): ReactElement => { - const safeAddress = useSafeAddress() - const { tokens } = params - - const [safeTx, safeTxError] = useAsync<SafeTransaction>(() => { - const calls = tokens.map((token) => { - return createNftTransferParams(safeAddress, params.recipient, token.id, token.address) - }) - return calls.length > 1 ? createMultiSendCallOnlyTx(calls) : createTx(calls[0]) - }, [safeAddress, params]) - - return ( - <SignOrExecuteForm safeTx={safeTx} onSubmit={onSubmit} error={safeTxError}> - <SendFromBlock title={`Sending ${tokens.length} NFT${tokens.length > 1 ? 's' : ''} from`} /> - - <SendToBlock address={params.recipient} title="To" /> - </SignOrExecuteForm> - ) -} - -export default ReviewNftBatch diff --git a/src/components/tx/modals/NftBatchModal/SendNftBatch.tsx b/src/components/tx/modals/NftBatchModal/SendNftBatch.tsx deleted file mode 100644 index 0f6dbe4feb..0000000000 --- a/src/components/tx/modals/NftBatchModal/SendNftBatch.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Box, Button, DialogContent, FormControl, Grid, SvgIcon, Typography } from '@mui/material' -import { FormProvider, useForm } from 'react-hook-form' -import NftIcon from '@/public/images/common/nft.svg' -import AddressBookInput from '@/components/common/AddressBookInput' -import SendFromBlock from '../../SendFromBlock' -import { type NftTransferParams } from '.' -import ImageFallback from '@/components/common/ImageFallback' -import useAddressBook from '@/hooks/useAddressBook' -import SendToBlock from '@/components/tx/SendToBlock' - -enum Field { - recipient = 'recipient', -} - -type FormData = { - [Field.recipient]: string -} - -export type SendNftBatchProps = { - onSubmit: (data: NftTransferParams) => void - params: NftTransferParams -} - -const NftItem = ({ image, name, description }: { image: string; name: string; description?: string }) => ( - <Grid container spacing={1} alignItems="center" wrap="nowrap" mb={2}> - <Grid item> - <Box width={20} height={20}> - <ImageFallback - src={image} - fallbackSrc="" - fallbackComponent={<SvgIcon component={NftIcon} inheritViewBox width={20} height={20} />} - alt={name} - height={20} - /> - </Box> - </Grid> - - <Grid item overflow="hidden"> - <Typography overflow="hidden" textOverflow="ellipsis"> - {name} - </Typography> - - {description && ( - <Typography variant="caption" color="primary.light" display="block" overflow="hidden" textOverflow="ellipsis"> - {description} - </Typography> - )} - </Grid> - </Grid> -) - -const SendNftBatch = ({ params, onSubmit }: SendNftBatchProps) => { - const addressBook = useAddressBook() - const { tokens } = params - - const formMethods = useForm<FormData>({ - defaultValues: { - [Field.recipient]: params.recipient || '', - }, - }) - const { handleSubmit, watch, setValue } = formMethods - - const recipient = watch(Field.recipient) - - const onFormSubmit = (data: FormData) => { - onSubmit({ - recipient: data.recipient, - tokens, - }) - } - - return ( - <FormProvider {...formMethods}> - <form onSubmit={handleSubmit(onFormSubmit)}> - <DialogContent> - <SendFromBlock title={`Sending ${tokens.length} NFT${tokens.length > 1 ? 's' : ''} from`} /> - - <FormControl fullWidth sx={{ mb: 2 }}> - {addressBook[recipient] ? ( - <Box onClick={() => setValue(Field.recipient, '')} mb={-1.5}> - <SendToBlock address={recipient} /> - </Box> - ) : ( - <> - <Typography color="text.secondary" pb={1}> - To - </Typography> - - <AddressBookInput name={Field.recipient} label="Recipient" /> - </> - )} - </FormControl> - - <Typography color="text.secondary" mb={1}> - Selected NFTs - </Typography> - - <Box overflow="auto" maxHeight="20vh" minHeight="54px" pr={1}> - {tokens.map((token) => ( - <NftItem - key={`${token.address}-${token.id}`} - image={token.imageUri || token.logoUri} - name={`${token.tokenName || token.tokenSymbol || ''} #${token.id}`} - description={`Token ID: ${token.id}${token.name ? ` - ${token.name}` : ''}`} - /> - ))} - </Box> - </DialogContent> - - <Button variant="contained" type="submit"> - Next - </Button> - </form> - </FormProvider> - ) -} - -export default SendNftBatch diff --git a/src/components/tx/modals/NftBatchModal/index.tsx b/src/components/tx/modals/NftBatchModal/index.tsx deleted file mode 100644 index 8ec56340b8..0000000000 --- a/src/components/tx/modals/NftBatchModal/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { type ReactElement } from 'react' -import { type SafeCollectibleResponse } from '@safe-global/safe-gateway-typescript-sdk' -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import type { TxModalProps } from '@/components/tx/TxModal' -import TxModal from '@/components/tx/TxModal' -import SendNftBatch from './SendNftBatch' -import ReviewNftBatch from './ReviewNftBatch' - -export type NftTransferParams = { - recipient: string - tokens: SafeCollectibleResponse[] -} - -export const NftTransferSteps: TxStepperProps['steps'] = [ - { - label: 'Send NFTs', - render: (data, onSubmit) => <SendNftBatch onSubmit={onSubmit} params={data as NftTransferParams} />, - }, - { - label: 'Review NFT transaction', - render: (data, onSubmit) => <ReviewNftBatch onSubmit={onSubmit} params={data as NftTransferParams} />, - }, -] - -const NftBatchModal = (props: Omit<TxModalProps, 'steps'>): ReactElement => { - return <TxModal {...props} steps={NftTransferSteps} /> -} - -export default NftBatchModal diff --git a/src/components/tx/modals/RejectTxModal/index.tsx b/src/components/tx/modals/RejectTxModal/index.tsx deleted file mode 100644 index 48b01ba826..0000000000 --- a/src/components/tx/modals/RejectTxModal/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react' - -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import type { TxModalProps } from '@/components/tx/TxModal' -import TxModal from '@/components/tx/TxModal' -import RejectTx from '@/components/tx/modals/RejectTxModal/RejectTx' - -export const RejectTxSteps: TxStepperProps['steps'] = [ - { - label: 'Reject transaction', - render: (data, onSubmit) => <RejectTx txNonce={data as number} onSubmit={onSubmit} />, - }, -] - -const RejectTxModal = (props: Omit<TxModalProps, 'steps'>) => { - return <TxModal {...props} steps={RejectTxSteps} /> -} - -export default RejectTxModal diff --git a/src/components/tx/modals/TokenTransferModal/ReviewMultisigTx.tsx b/src/components/tx/modals/TokenTransferModal/ReviewMultisigTx.tsx deleted file mode 100644 index 7549ede416..0000000000 --- a/src/components/tx/modals/TokenTransferModal/ReviewMultisigTx.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { type ReactElement } from 'react' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' - -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import { createTokenTransferParams } from '@/services/tx/tokenTransferParams' -import useBalances from '@/hooks/useBalances' -import useAsync from '@/hooks/useAsync' -import SendToBlock from '@/components/tx/SendToBlock' -import SendFromBlock from '../../SendFromBlock' -import type { TokenTransferModalProps } from '.' -import { TokenTransferReview } from '@/components/tx/modals/TokenTransferModal/ReviewTokenTx' -import { createTx } from '@/services/tx/tx-sender' - -const ReviewMultisigTx = ({ params, onSubmit }: TokenTransferModalProps): ReactElement => { - const { balances } = useBalances() - - const token = balances.items.find((item) => item.tokenInfo.address === params.tokenAddress) - const { decimals, address } = token?.tokenInfo || {} - - // Create a safeTx - const [safeTx, safeTxError] = useAsync<SafeTransaction>(() => { - if (!address || typeof decimals === 'undefined') return - const txParams = createTokenTransferParams(params.recipient, params.amount, decimals, address) - return createTx(txParams, params.txNonce) - }, [params, decimals, address]) - - return ( - <SignOrExecuteForm safeTx={safeTx} onSubmit={onSubmit} error={safeTxError}> - {token && <TokenTransferReview amount={params.amount} tokenInfo={token.tokenInfo} />} - - <SendFromBlock /> - - <SendToBlock address={params.recipient} /> - </SignOrExecuteForm> - ) -} - -export default ReviewMultisigTx diff --git a/src/components/tx/modals/TokenTransferModal/ReviewTokenTx.tsx b/src/components/tx/modals/TokenTransferModal/ReviewTokenTx.tsx deleted file mode 100644 index 4da0cd7353..0000000000 --- a/src/components/tx/modals/TokenTransferModal/ReviewTokenTx.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { type ReactNode, type ReactElement } from 'react' -import { Box } from '@mui/material' -import type { TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' - -import css from './styles.module.css' -import type { TokenTransferModalProps } from '.' -import { SendTxType } from '@/components/tx/modals/TokenTransferModal/SendAssetsForm' -import TokenIcon from '@/components/common/TokenIcon' -import ReviewSpendingLimitTx from '@/components/tx/modals/TokenTransferModal/ReviewSpendingLimitTx' -import ReviewMultisigTx from '@/components/tx/modals/TokenTransferModal/ReviewMultisigTx' -import { formatAmountPrecise } from '@/utils/formatNumber' - -export const TokenTransferReview = ({ - amount, - tokenInfo, - children, -}: { - amount: number | string - tokenInfo: TokenInfo - children?: ReactNode -}) => { - return ( - <Box className={css.tokenPreview}> - <Box className={css.tokenIcon}> - <TokenIcon logoUri={tokenInfo.logoUri} tokenSymbol={tokenInfo.symbol} /> - </Box> - - <Box mt={1} fontSize={20}> - {children} - {formatAmountPrecise(amount, tokenInfo.decimals)} {tokenInfo.symbol} - </Box> - </Box> - ) -} - -const ReviewTokenTx = ({ params, onSubmit }: TokenTransferModalProps): ReactElement => { - const isSpendingLimitTx = params.type === SendTxType.spendingLimit - - return isSpendingLimitTx ? ( - <ReviewSpendingLimitTx params={params} onSubmit={onSubmit} /> - ) : ( - <ReviewMultisigTx params={params} onSubmit={onSubmit} /> - ) -} - -export default ReviewTokenTx diff --git a/src/components/tx/modals/TokenTransferModal/SendAssetsForm.tsx b/src/components/tx/modals/TokenTransferModal/SendAssetsForm.tsx deleted file mode 100644 index d7dd4ede00..0000000000 --- a/src/components/tx/modals/TokenTransferModal/SendAssetsForm.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import type { ReactElement } from 'react' -import { useCallback, useMemo } from 'react' -import { useForm, FormProvider, Controller } from 'react-hook-form' -import { - Button, - FormControl, - Grid, - InputLabel, - MenuItem, - Select, - Typography, - DialogContent, - Box, - SvgIcon, -} from '@mui/material' -import { type TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { BigNumber } from '@ethersproject/bignumber' - -import TokenIcon from '@/components/common/TokenIcon' -import { formatVisualAmount, safeFormatUnits } from '@/utils/formatters' -import { validateDecimalLength, validateLimitedAmount } from '@/utils/validation' -import AddressBookInput from '@/components/common/AddressBookInput' -import InputValueHelper from '@/components/common/InputValueHelper' -import SendFromBlock from '../../SendFromBlock' -import SpendingLimitRow from '@/components/tx/SpendingLimitRow' -import useSpendingLimit from '@/hooks/useSpendingLimit' -import SendToBlock from '@/components/tx/SendToBlock' -import useAddressBook from '@/hooks/useAddressBook' -import { getSafeTokenAddress } from '@/components/common/SafeTokenWidget' -import useChainId from '@/hooks/useChainId' -import { sameAddress } from '@/utils/addresses' -import InfoIcon from '@/public/images/notifications/info.svg' -import useIsSafeTokenPaused from '@/components/tx/modals/TokenTransferModal/useIsSafeTokenPaused' -import NumberField from '@/components/common/NumberField' -import { useVisibleBalances } from '@/hooks/useVisibleBalances' -import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' -import { useAppSelector } from '@/store' -import { selectSpendingLimits } from '@/store/spendingLimitsSlice' -import useWallet from '@/hooks/wallets/useWallet' - -export const AutocompleteItem = (item: { tokenInfo: TokenInfo; balance: string }): ReactElement => ( - <Grid container alignItems="center" gap={1}> - <TokenIcon logoUri={item.tokenInfo.logoUri} tokenSymbol={item.tokenInfo.symbol} /> - - <Grid item xs> - <Typography variant="body2">{item.tokenInfo.name}</Typography> - - <Typography variant="caption" component="p"> - {formatVisualAmount(item.balance, item.tokenInfo.decimals)} {item.tokenInfo.symbol} - </Typography> - </Grid> - </Grid> -) - -export enum SendTxType { - multiSig = 'multiSig', - spendingLimit = 'spendingLimit', -} - -export enum SendAssetsField { - recipient = 'recipient', - tokenAddress = 'tokenAddress', - amount = 'amount', - type = 'type', -} - -export type SendAssetsFormData = { - [SendAssetsField.recipient]: string - [SendAssetsField.tokenAddress]: string - [SendAssetsField.amount]: string - [SendAssetsField.type]: SendTxType -} - -type SendAssetsFormProps = { - formData?: SendAssetsFormData - disableSpendingLimit?: boolean - onSubmit: (formData: SendAssetsFormData) => void -} - -const SendAssetsForm = ({ - onSubmit, - formData, - // Spending limits only disabled upon replacement, which pure spending limit beneficiaries can't do - disableSpendingLimit = false, -}: SendAssetsFormProps): ReactElement => { - const { balances } = useVisibleBalances() - const addressBook = useAddressBook() - const chainId = useChainId() - const safeTokenAddress = getSafeTokenAddress(chainId) - const isSafeTokenPaused = useIsSafeTokenPaused() - const isOnlySpendingLimitBeneficiary = useIsOnlySpendingLimitBeneficiary() - const spendingLimits = useAppSelector(selectSpendingLimits) - const wallet = useWallet() - - const formMethods = useForm<SendAssetsFormData>({ - defaultValues: { - [SendAssetsField.recipient]: formData?.[SendAssetsField.recipient] || '', - [SendAssetsField.tokenAddress]: formData?.[SendAssetsField.tokenAddress] || '', - [SendAssetsField.amount]: formData?.[SendAssetsField.amount] || '', - [SendAssetsField.type]: disableSpendingLimit - ? SendTxType.multiSig - : isOnlySpendingLimitBeneficiary - ? SendTxType.spendingLimit - : formData?.[SendAssetsField.type] || SendTxType.multiSig, - }, - mode: 'onChange', - delayError: 500, - }) - const { - register, - handleSubmit, - setValue, - resetField, - watch, - formState: { errors }, - control, - } = formMethods - - const recipient = watch(SendAssetsField.recipient) - - // Selected token - const tokenAddress = watch(SendAssetsField.tokenAddress) - const selectedToken = tokenAddress - ? balances.items.find((item) => item.tokenInfo.address === tokenAddress) - : undefined - - const type = watch(SendAssetsField.type) - const spendingLimit = useSpendingLimit(selectedToken?.tokenInfo) - const isSpendingLimitType = type === SendTxType.spendingLimit - const spendingLimitAmount = spendingLimit ? BigNumber.from(spendingLimit.amount).sub(spendingLimit.spent) : undefined - const totalAmount = BigNumber.from(selectedToken?.balance || 0) - const maxAmount = isSpendingLimitType - ? spendingLimitAmount && totalAmount.gt(spendingLimitAmount) - ? spendingLimitAmount - : totalAmount - : totalAmount - - const balancesItems = useMemo(() => { - return isOnlySpendingLimitBeneficiary - ? balances.items.filter(({ tokenInfo }) => { - return spendingLimits?.some(({ beneficiary, token }) => { - return sameAddress(beneficiary, wallet?.address || '') && sameAddress(tokenInfo.address, token.address) - }) - }) - : balances.items - }, [balances.items, isOnlySpendingLimitBeneficiary, spendingLimits, wallet?.address]) - - const onMaxAmountClick = useCallback(() => { - if (!selectedToken) return - - const amount = - isSpendingLimitType && spendingLimitAmount && spendingLimitAmount.lte(selectedToken.balance) - ? spendingLimitAmount.toString() - : selectedToken.balance - - setValue(SendAssetsField.amount, safeFormatUnits(amount, selectedToken.tokenInfo.decimals), { - shouldValidate: true, - }) - }, [isSpendingLimitType, selectedToken, setValue, spendingLimitAmount]) - - const isSafeTokenSelected = sameAddress(safeTokenAddress, tokenAddress) - const isDisabled = isSafeTokenSelected && isSafeTokenPaused - - return ( - <FormProvider {...formMethods}> - <form onSubmit={handleSubmit(onSubmit)}> - <DialogContent> - <SendFromBlock /> - - <FormControl fullWidth sx={{ mb: 2, mt: 1 }}> - {addressBook[recipient] ? ( - <Box onClick={() => setValue(SendAssetsField.recipient, '')}> - <SendToBlock address={recipient} /> - </Box> - ) : ( - <AddressBookInput name={SendAssetsField.recipient} label="Recipient" /> - )} - </FormControl> - - <Controller - name={SendAssetsField.tokenAddress} - control={control} - rules={{ required: true }} - render={({ fieldState, field }) => ( - <FormControl fullWidth> - <InputLabel id="asset-label" required> - Select an asset - </InputLabel> - <Select - labelId="asset-label" - label={fieldState.error?.message || 'Select an asset'} - error={!!fieldState.error} - {...field} - onChange={(e) => { - field.onChange(e) - resetField(SendAssetsField.amount) - }} - > - {balancesItems.map((item) => ( - <MenuItem key={item.tokenInfo.address} value={item.tokenInfo.address}> - <AutocompleteItem {...item} /> - </MenuItem> - ))} - </Select> - </FormControl> - )} - /> - - {isDisabled && ( - <Box mt={1} display="flex" alignItems="center"> - <SvgIcon component={InfoIcon} color="error" fontSize="small" /> - <Typography variant="body2" color="error" ml={0.5}> - $SAFE is currently non-transferable. - </Typography> - </Box> - )} - - {!disableSpendingLimit && !!spendingLimitAmount && ( - <FormControl fullWidth sx={{ mt: 2 }}> - <SpendingLimitRow availableAmount={spendingLimitAmount} selectedToken={selectedToken?.tokenInfo} /> - </FormControl> - )} - - <FormControl fullWidth sx={{ mt: 2 }}> - <NumberField - label={errors.amount?.message || 'Amount'} - error={!!errors.amount} - InputProps={{ - endAdornment: ( - <InputValueHelper onClick={onMaxAmountClick} disabled={!selectedToken}> - Max - </InputValueHelper> - ), - }} - // @see https://github.com/react-hook-form/react-hook-form/issues/220 - InputLabelProps={{ - shrink: !!watch(SendAssetsField.amount), - }} - required - {...register(SendAssetsField.amount, { - required: true, - validate: (val) => { - const decimals = selectedToken?.tokenInfo.decimals - return ( - validateLimitedAmount(val, decimals, maxAmount.toString()) || validateDecimalLength(val, decimals) - ) - }, - })} - /> - </FormControl> - </DialogContent> - - <Button variant="contained" type="submit" disabled={isDisabled}> - Next - </Button> - </form> - </FormProvider> - ) -} - -export default SendAssetsForm diff --git a/src/components/tx/modals/TokenTransferModal/index.tsx b/src/components/tx/modals/TokenTransferModal/index.tsx deleted file mode 100644 index b5feadcb5f..0000000000 --- a/src/components/tx/modals/TokenTransferModal/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react' - -import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' -import type { SendAssetsFormData } from '@/components/tx/modals/TokenTransferModal/SendAssetsForm' -import SendAssetsForm from '@/components/tx/modals/TokenTransferModal/SendAssetsForm' -import ReviewTokenTx from '@/components/tx/modals/TokenTransferModal/ReviewTokenTx' -import type { TxModalProps } from '@/components/tx/TxModal' -import TxModal from '@/components/tx/TxModal' - -export type TokenTransferModalProps = { - params: SendAssetsFormData & { txNonce?: number; disableSpendingLimit?: boolean } - onSubmit: () => void -} - -export const TokenTransferSteps: TxStepperProps['steps'] = [ - { - label: 'Send tokens', - render: (data, onSubmit) => { - const { disableSpendingLimit, ...formData } = data as SendAssetsFormData & { disableSpendingLimit?: boolean } - return <SendAssetsForm onSubmit={onSubmit} formData={formData} disableSpendingLimit={disableSpendingLimit} /> - }, - }, - { - label: 'Review transaction', - render: (data, onSubmit) => ( - <ReviewTokenTx - onSubmit={onSubmit} - params={data as Omit<TokenTransferModalProps['params'], 'disableSpendingLimit'>} - /> - ), - }, -] - -const TokenTransferModal = (props: Omit<TxModalProps, 'steps'>) => { - return <TxModal {...props} steps={TokenTransferSteps} /> -} - -export default TokenTransferModal diff --git a/src/components/tx/modals/TokenTransferModal/styles.module.css b/src/components/tx/modals/TokenTransferModal/styles.module.css deleted file mode 100644 index dc1718b7b6..0000000000 --- a/src/components/tx/modals/TokenTransferModal/styles.module.css +++ /dev/null @@ -1,16 +0,0 @@ -.container { - display: flex; - flex-direction: column; - flex-wrap: wrap; - gap: var(--space-3); - padding: var(--space-3); -} - -.tokenPreview { - text-align: center; -} - -.tokenIcon { - display: flex; - justify-content: center; -} diff --git a/src/components/tx/security/redefine/RedefineBalanceChange/index.tsx b/src/components/tx/security/redefine/RedefineBalanceChange.tsx similarity index 66% rename from src/components/tx/security/redefine/RedefineBalanceChange/index.tsx rename to src/components/tx/security/redefine/RedefineBalanceChange.tsx index 4cea2fd0c5..538d8d5c6b 100644 --- a/src/components/tx/security/redefine/RedefineBalanceChange/index.tsx +++ b/src/components/tx/security/redefine/RedefineBalanceChange.tsx @@ -7,19 +7,18 @@ import { type RedefineModuleResponse } from '@/services/security/modules/Redefin import { sameAddress } from '@/utils/addresses' import { FEATURES } from '@/utils/chains' import { formatVisualAmount } from '@/utils/formatters' -import { Box, Chip, Grid, SvgIcon, Typography } from '@mui/material' +import { Box, Chip, CircularProgress, Grid, SvgIcon, Tooltip, Typography } from '@mui/material' import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' import { ErrorBoundary } from '@sentry/react' import { useContext } from 'react' -import { LoadingLabel } from '../../shared/LoadingLabel' -import { TransactionSecurityContext } from '../../TransactionSecurityContext' -import RedefineLogo from '@/public/images/transactions/redefine.svg' -import RedefineLogoDark from '@/public/images/transactions/redefine-dark-mode.svg' +import { TxSecurityContext } from '../shared/TxSecurityContext' import ArrowOutwardIcon from '@/public/images/transactions/outgoing.svg' import ArrowDownwardIcon from '@/public/images/transactions/incoming.svg' +import InfoIcon from '@/public/images/notifications/info.svg' +import ExternalLink from '@/components/common/ExternalLink' +import { REDEFINE_ARTICLE } from '@/config/constants' import css from './styles.module.css' -import { useDarkMode } from '@/hooks/useDarkMode' const FungibleBalanceChange = ({ change, @@ -43,6 +42,7 @@ const FungibleBalanceChange = ({ <Typography variant="body2" fontWeight={700} display="inline" ml={0.5}> {change.symbol} </Typography> + <span style={{ margin: 'auto' }} /> <Chip className={css.categoryChip} label={change.type} /> </> ) @@ -77,6 +77,7 @@ const NFTBalanceChange = ({ <Typography variant="subtitle2" className={css.nftId} ml={1}> #{change.tokenId} </Typography> + <span style={{ margin: 'auto' }} /> <Chip className={css.categoryChip} label="NFT" /> </> ) @@ -100,17 +101,29 @@ const BalanceChange = ({ } const BalanceChanges = () => { - const { balanceChange, isLoading } = useContext(TransactionSecurityContext) - const totalBalanceChanges = balanceChange ? balanceChange.in.length + balanceChange.out.length : 0 + const { balanceChange, isLoading } = useContext(TxSecurityContext) + const totalBalanceChanges = balanceChange ? balanceChange.in.length + balanceChange.out.length : undefined if (isLoading && !balanceChange) { - return <LoadingLabel /> + return ( + <div className={css.loader}> + <CircularProgress + size={22} + sx={{ + color: ({ palette }) => palette.text.secondary, + }} + /> + <Typography variant="body2" color="text.secondary"> + Calculating... + </Typography> + </div> + ) } - if (totalBalanceChanges === 0) { + if (totalBalanceChanges && totalBalanceChanges === 0) { return ( - <Typography color="text.secondary" p={2}> - None + <Typography variant="body2" color="text.secondary" justifySelf="flex-end"> + No balance change detected </Typography> ) } @@ -131,27 +144,46 @@ const BalanceChanges = () => { export const RedefineBalanceChanges = () => { const isFeatureEnabled = useHasFeature(FEATURES.RISK_MITIGATION) - const isDarkMode = useDarkMode() if (!isFeatureEnabled) { return null } return ( - <Box className={css.box}> - <Box className={css.head}> - <Typography variant="subtitle2" fontWeight={700}> - Balance change - </Typography> - <SvgIcon - inheritViewBox - sx={{ height: '40px', width: '52px' }} - component={isDarkMode ? RedefineLogoDark : RedefineLogo} - /> - </Box> + <div className={css.box}> + <Typography variant="subtitle2" fontWeight={700} flexShrink={0}> + Balance change + <Tooltip + title={ + <> + The balance change gives an overview of the implications of a transaction. You can see which assets will + be sent and received after the transaction is executed.  + <ExternalLink href={REDEFINE_ARTICLE} title="Learn more about balance change"> + Learn more about balance change + </ExternalLink> + . + </> + } + arrow + placement="top" + > + <span> + <SvgIcon + component={InfoIcon} + inheritViewBox + color="border" + fontSize="small" + sx={{ + verticalAlign: 'middle', + ml: 0.5, + }} + /> + </span> + </Tooltip> + </Typography> <ErrorBoundary fallback={<div>Error showing balance changes</div>}> <BalanceChanges /> </ErrorBoundary> - </Box> + </div> ) } diff --git a/src/components/tx/security/redefine/RedefineBalanceChange/styles.module.css b/src/components/tx/security/redefine/RedefineBalanceChange/styles.module.css deleted file mode 100644 index e94196cc52..0000000000 --- a/src/components/tx/security/redefine/RedefineBalanceChange/styles.module.css +++ /dev/null @@ -1,52 +0,0 @@ -.balanceChanges { - padding: var(--space-2); - max-height: 300px; - overflow-y: auto; - align-items: center; - gap: 1px; -} - -.balanceChange { - display: flex; - border-radius: 6px; - align-items: center; - margin-bottom: 6px; -} - -.balanceChange:last-child { - margin-bottom: 0; -} - -.balanceChange svg { - flex-shrink: 0; -} - -.nftId { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.categoryChip { - border-radius: 4px; - margin-left: auto; - height: auto; -} - -.head { - border-bottom: 1px solid var(--color-border-light); - padding: var(--space-1) var(--space-2); - - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; -} - -.box { - border-radius: 6px; - border: 1px solid var(--color-border-light); - margin: var(--space-2) 0; - display: flex; - flex-direction: column; -} diff --git a/src/components/tx/security/redefine/RedefineHint.tsx b/src/components/tx/security/redefine/RedefineHint.tsx new file mode 100644 index 0000000000..8030ec9ec4 --- /dev/null +++ b/src/components/tx/security/redefine/RedefineHint.tsx @@ -0,0 +1,52 @@ +import { SecuritySeverity } from '@/services/security/modules/types' +import { Alert, Box, List, ListItem, SvgIcon, Typography } from '@mui/material' +import css from 'src/components/tx/security/redefine/styles.module.css' +import AlertIcon from '@/public/images/notifications/alert.svg' +import { mapRedefineSeverity } from '@/components/tx/security/redefine/useRedefine' + +export const RedefineHint = ({ severity, warnings }: { severity: SecuritySeverity; warnings: string[] }) => { + const severityProps = mapRedefineSeverity[severity] + const pluralizedLabel = ( + <> + {warnings.length} {severityProps.label} + {warnings.length > 1 ? 's' : ''} + </> + ) + + return ( + <> + <Alert + className={css.hint} + severity={severityProps.color} + sx={{ bgcolor: ({ palette }) => palette[severityProps.color].background }} + icon={ + <SvgIcon + component={AlertIcon} + inheritViewBox + color={severityProps.color} + sx={{ + '& path': { + fill: ({ palette }) => palette[severityProps.color].main, + }, + }} + /> + } + > + {severity !== SecuritySeverity.NONE && ( + <Typography variant="body2" fontWeight={700}> + {pluralizedLabel} + </Typography> + )} + <Box display="flex" flexDirection="column" gap={2}> + <List sx={{ listStyle: 'disc', pl: 2, '& li:last-child': { m: 0 } }}> + {warnings.map((warning) => ( + <ListItem key={warning} disablePadding sx={{ display: 'list-item', mb: 1 }}> + <Typography variant="body2">{warning}</Typography> + </ListItem> + ))} + </List> + </Box> + </Alert> + </> + ) +} diff --git a/src/components/tx/security/redefine/RedefineScanResult/RedefineScanResult.tsx b/src/components/tx/security/redefine/RedefineScanResult/RedefineScanResult.tsx deleted file mode 100644 index a800657b45..0000000000 --- a/src/components/tx/security/redefine/RedefineScanResult/RedefineScanResult.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useContext } from 'react' -import { SecurityHint, SecurityWarning } from '../../shared/SecurityWarnings' -import { TransactionSecurityContext } from '../../TransactionSecurityContext' -import { SecuritySeverity } from '@/services/security/modules/types' -import { groupBy } from 'lodash' -import { Box, Typography } from '@mui/material' -import ExternalLink from '@/components/common/ExternalLink' -import { FEATURES } from '@/utils/chains' -import { useHasFeature } from '@/hooks/useChains' -import { ErrorBoundary } from '@sentry/react' -import { REDEFINE_SIMULATION_URL } from '@/config/constants' - -const MAX_SHOWN_WARNINGS = 3 - -const ScanWarnings = () => { - /* Hooks */ - const { - warnings, - severity, - isLoading, - error, - simulationUuid, - needsRiskConfirmation, - isRiskConfirmed, - setIsRiskConfirmed, - } = useContext(TransactionSecurityContext) - - /* Evaluate security warnings */ - const relevantWarnings = warnings.filter((warning) => warning.severity !== SecuritySeverity.NONE) - const shownWarnings = relevantWarnings.slice(0, MAX_SHOWN_WARNINGS) - const hiddenWarningCount = warnings.length - shownWarnings.length - const hiddenMaxSeverity = hiddenWarningCount > 0 ? relevantWarnings[MAX_SHOWN_WARNINGS]?.severity : 0 - - const groupedShownWarnings = groupBy(shownWarnings, (warning) => warning.severity) - const sortedSeverities = Object.keys(groupedShownWarnings).sort((a, b) => (Number(a) < Number(b) ? 1 : -1)) - - return ( - <SecurityWarning - severity={severity} - isLoading={isLoading} - error={error} - isConfirmed={isRiskConfirmed} - needsConfirmation={needsRiskConfirmation} - setIsConfirmed={setIsRiskConfirmed} - > - <Box display="flex" flexDirection="column" gap={1}> - {sortedSeverities.map((key) => ( - <SecurityHint - key={key} - severity={Number(key)} - warnings={groupedShownWarnings[key].map((warning) => warning.description.short)} - /> - ))} - {hiddenWarningCount > 0 && ( - <SecurityHint - severity={hiddenMaxSeverity} - warnings={[`${hiddenWarningCount} more issue${hiddenWarningCount > 1 ? 's' : ''}`]} - /> - )} - - {simulationUuid && ( - <Typography> - For a comprehensive risk overview,{' '} - <ExternalLink href={`${REDEFINE_SIMULATION_URL}${simulationUuid}`}> - see the full report on Redefine - </ExternalLink> - </Typography> - )} - </Box> - </SecurityWarning> - ) -} - -export const RedefineScanResult = () => { - const isFeatureEnabled = useHasFeature(FEATURES.RISK_MITIGATION) - - if (!isFeatureEnabled) { - return null - } - - return ( - <ErrorBoundary fallback={<div>Error showing scan result</div>}> - <ScanWarnings /> - </ErrorBoundary> - ) -} diff --git a/src/components/tx/security/redefine/index.tsx b/src/components/tx/security/redefine/index.tsx new file mode 100644 index 0000000000..f02ffcd23e --- /dev/null +++ b/src/components/tx/security/redefine/index.tsx @@ -0,0 +1,195 @@ +import { useContext, useEffect, useRef } from 'react' +import { mapRedefineSeverity } from '@/components/tx/security/redefine/useRedefine' +import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext' +import { SecuritySeverity } from '@/services/security/modules/types' +import { groupBy } from 'lodash' +import { Alert, Box, Checkbox, FormControlLabel, Paper, SvgIcon, Tooltip, Typography } from '@mui/material' +import ExternalLink from '@/components/common/ExternalLink' +import { FEATURES } from '@/utils/chains' +import { useHasFeature } from '@/hooks/useChains' +import { ErrorBoundary } from '@sentry/react' +import { REDEFINE_ARTICLE, REDEFINE_SIMULATION_URL } from '@/config/constants' +import css from 'src/components/tx/security/redefine/styles.module.css' +import sharedCss from '@/components/tx/security/shared/styles.module.css' +import RedefineLogoDark from '@/public/images/transactions/redefine-dark-mode.svg' +import RedefineLogo from '@/public/images/transactions/redefine.svg' +import Track from '@/components/common/Track' +import { MODALS_EVENTS } from '@/services/analytics' +import { useDarkMode } from '@/hooks/useDarkMode' +import CircularProgress from '@mui/material/CircularProgress' +import { RedefineHint } from '@/components/tx/security/redefine/RedefineHint' +import InfoIcon from '@/public/images/notifications/info.svg' + +const MAX_SHOWN_WARNINGS = 3 + +const RedefineBlock = () => { + const { severity, isLoading, error, needsRiskConfirmation, isRiskConfirmed, setIsRiskConfirmed, isRiskIgnored } = + useContext(TxSecurityContext) + const checkboxRef = useRef<HTMLElement>(null) + + const isDarkMode = useDarkMode() + const severityProps = severity !== undefined ? mapRedefineSeverity[severity] : undefined + + const toggleConfirmation = () => { + setIsRiskConfirmed((prev) => !prev) + } + + // Highlight checkbox if user tries to submit transaction without confirming risks + useEffect(() => { + if (isRiskIgnored) { + checkboxRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + }, [isRiskIgnored, checkboxRef]) + + return ( + <div className={css.wrapperBox}> + <Paper + variant="outlined" + className={sharedCss.wrapper} + sx={needsRiskConfirmation ? { borderTop: 'none', borderLeft: 'none', borderRight: 'none' } : { border: 'none' }} + > + <div> + <Typography variant="body2" fontWeight={700}> + Scan for risks + <Tooltip + title={ + <> + This transaction has been automatically scanned for risks to help prevent scams.  + <ExternalLink href={REDEFINE_ARTICLE} title="Learn more about security scans"> + Learn more about security scans + </ExternalLink> + . + </> + } + arrow + placement="top" + > + <span> + <SvgIcon + component={InfoIcon} + inheritViewBox + color="border" + fontSize="small" + sx={{ + verticalAlign: 'middle', + ml: 0.5, + }} + /> + </span> + </Tooltip> + </Typography> + + <Typography variant="caption" className={sharedCss.poweredBy} position="relative"> + Powered by{' '} + <SvgIcon + inheritViewBox + sx={{ height: '40px', width: '52px', position: 'absolute', right: '-58px' }} + component={isDarkMode ? RedefineLogoDark : RedefineLogo} + /> + </Typography> + </div> + + <div className={sharedCss.result}> + {isLoading ? ( + <CircularProgress + size={22} + sx={{ + color: ({ palette }) => palette.text.secondary, + }} + /> + ) : severityProps ? ( + <Typography variant="body2" color={`${severityProps.color}.main`} className={sharedCss.result}> + <SvgIcon + component={severityProps.icon} + inheritViewBox + fontSize="small" + sx={{ verticalAlign: 'middle', mr: 1 }} + /> + {severityProps.label} + </Typography> + ) : error ? ( + <Typography variant="body2" color="error" className={sharedCss.result}> + {error.message} + </Typography> + ) : null} + </div> + </Paper> + <div> + {needsRiskConfirmation && ( + <Box pl={2} ref={checkboxRef}> + <Track {...MODALS_EVENTS.ACCEPT_RISK}> + <FormControlLabel + label="I understand the risks and would like to continue this transaction" + control={<Checkbox checked={isRiskConfirmed} onChange={toggleConfirmation} />} + className={isRiskIgnored ? css.checkboxError : ''} + /> + </Track> + </Box> + )} + </div> + </div> + ) +} + +export const Redefine = () => { + const isFeatureEnabled = useHasFeature(FEATURES.RISK_MITIGATION) + + if (!isFeatureEnabled) { + return null + } + + return ( + <ErrorBoundary fallback={<div>Error showing scan result</div>}> + <RedefineBlock /> + </ErrorBoundary> + ) +} + +export const RedefineMessage = () => { + const { severity, warnings, simulationUuid } = useContext(TxSecurityContext) + + /* Evaluate security warnings */ + const relevantWarnings = warnings.filter((warning) => warning.severity !== SecuritySeverity.NONE) + const shownWarnings = relevantWarnings.slice(0, MAX_SHOWN_WARNINGS) + const hiddenWarningCount = warnings.length - shownWarnings.length + const hiddenMaxSeverity = + hiddenWarningCount > 0 ? relevantWarnings[MAX_SHOWN_WARNINGS]?.severity : SecuritySeverity.NONE + + const groupedShownWarnings = groupBy(shownWarnings, (warning) => warning.severity) + const sortedSeverities = Object.keys(groupedShownWarnings).sort((a, b) => (Number(a) < Number(b) ? 1 : -1)) + + if (sortedSeverities.length === 0 && hiddenWarningCount === 0 && !simulationUuid) return null + + return ( + <Box display="flex" flexDirection="column" gap={1}> + {sortedSeverities.map((key) => ( + <RedefineHint + key={key} + severity={Number(key)} + warnings={groupedShownWarnings[key].map((warning) => warning.description.short)} + /> + ))} + + {hiddenWarningCount > 0 && ( + <RedefineHint + severity={hiddenMaxSeverity} + warnings={[`${hiddenWarningCount} more issue${hiddenWarningCount > 1 ? 's' : ''}`]} + /> + )} + + {simulationUuid && ( + <Alert severity="info" sx={{ border: 'unset' }}> + {severity === SecuritySeverity.NONE && ( + <Typography variant="body2" fontWeight={700}> + {mapRedefineSeverity[severity].label} + </Typography> + )} + For a comprehensive risk overview,{' '} + <ExternalLink href={`${REDEFINE_SIMULATION_URL}${simulationUuid}`}> + see the full report on Redefine + </ExternalLink> + </Alert> + )} + </Box> + ) +} diff --git a/src/components/tx/security/redefine/styles.module.css b/src/components/tx/security/redefine/styles.module.css new file mode 100644 index 0000000000..552ed28b5e --- /dev/null +++ b/src/components/tx/security/redefine/styles.module.css @@ -0,0 +1,93 @@ +.hint { + padding: var(--space-2); + border: unset; +} + +.hint :global .MuiAlert-icon { + padding: 0; +} + +.hint :global .MuiAlert-message { + padding: 0; +} + +.wrapperBox :global .MuiAccordion-root.Mui-expanded { + border-color: var(--color-border-light) !important; +} + +.wrapperBox { + border-radius: 6px; + border: 1px solid var(--color-border-light); + padding: 0; + background-color: var(--color-background-main); + line-height: 1; +} + +.loader { + display: flex; + align-items: center; + gap: var(--space-1); + padding-right: 12px; + justify-self: flex-end; +} + +.balanceChanges { + max-height: 300px; + overflow-y: auto; + align-items: center; + gap: var(--space-1); +} + +.balanceChange { + display: flex; + margin-bottom: 6px; + align-items: center; +} + +.balanceChange:last-child { + margin-bottom: 0; +} + +.balanceChange svg { + flex-shrink: 0; +} + +.nftId { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.categoryChip { + border-radius: 4px; + height: auto; +} + +.box { + border-radius: 6px; + border: 1px solid var(--color-border-light); + display: grid; + grid-template-columns: 35% auto; + padding: var(--space-2) 12px; +} + +@keyframes popup { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + } +} + +.checkboxError { + color: var(--color-error-main); + animation: popup 0.5s ease-in-out; +} + +.checkboxError svg { + color: var(--color-error-main) !important; +} diff --git a/src/components/tx/security/redefine/useRedefine.ts b/src/components/tx/security/redefine/useRedefine.ts index d2b1f5ab2a..96a7621a7e 100644 --- a/src/components/tx/security/redefine/useRedefine.ts +++ b/src/components/tx/security/redefine/useRedefine.ts @@ -12,6 +12,11 @@ import type { SecurityResponse } from '@/services/security/modules/types' import { FEATURES } from '@/utils/chains' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { useState, useEffect, useMemo } from 'react' +import { type AlertColor, type SvgIconProps } from '@mui/material' +import { SecuritySeverity } from '@/services/security/modules/types' +import CloseIcon from '@/public/images/common/close.svg' +import InfoIcon from '@/public/images/notifications/info.svg' +import CheckIcon from '@/public/images/common/check.svg' export const REDEFINE_RETRY_TIMEOUT = 2_000 const RedefineModuleInstance = new RedefineModule() @@ -24,6 +29,49 @@ const CRITICAL_ERRORS: Record<number, string> = { [3000]: DEFAULT_ERROR_MESSAGE, } +type SecurityWarningProps = { + color: AlertColor + // @ts-expect-error - Use any to avoid conflicts with @svgr/webpack plugin or babel-plugin-inline-react-svg plugin. + icon: SvgIconProps['component'] + label: string + action?: string +} + +const ACTION_REJECT = 'Reject this transaction' +const ACTION_REVIEW = 'Review before processing' + +export const mapRedefineSeverity: Record<SecuritySeverity, SecurityWarningProps> = { + [SecuritySeverity.CRITICAL]: { + action: ACTION_REJECT, + color: 'error', + icon: CloseIcon, + label: 'Critical issue', + }, + [SecuritySeverity.HIGH]: { + action: ACTION_REJECT, + color: 'error', + icon: CloseIcon, + label: 'High issue', + }, + [SecuritySeverity.MEDIUM]: { + action: ACTION_REVIEW, + color: 'warning', + icon: InfoIcon, + label: 'Medium issue', + }, + [SecuritySeverity.LOW]: { + action: ACTION_REVIEW, + color: 'warning', + icon: InfoIcon, + label: 'Low issue', + }, + [SecuritySeverity.NONE]: { + color: 'success', + icon: CheckIcon, + label: 'No issues found', + }, +} + export const useRedefine = ( safeTransaction: SafeTransaction | undefined, ): AsyncResult<SecurityResponse<RedefineModuleResponse>> => { diff --git a/src/components/tx/security/shared/LoadingLabel.tsx b/src/components/tx/security/shared/LoadingLabel.tsx deleted file mode 100644 index 268b01dff1..0000000000 --- a/src/components/tx/security/shared/LoadingLabel.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Typography, CircularProgress } from '@mui/material' - -export const LoadingLabel = () => { - return ( - <Typography variant="body2" color="text.secondary" display="flex" alignItems="center" gap={1} p={2}> - <CircularProgress - thickness={2} - size={24} - sx={{ - color: ({ palette }) => palette.text.secondary, - }} - /> - Calculating... - </Typography> - ) -} diff --git a/src/components/tx/security/shared/SecurityWarnings/index.test.ts b/src/components/tx/security/shared/SecurityWarnings/index.test.ts deleted file mode 100644 index b646fbd37d..0000000000 --- a/src/components/tx/security/shared/SecurityWarnings/index.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { SecuritySeverity } from '@/services/security/modules/types' -import { mapSeverityComponentProps } from '.' - -describe('SecurityWarnings', () => { - describe('mapSeverity', () => { - it('should return "error" when the severity is "HIGH', () => { - expect(mapSeverityComponentProps[SecuritySeverity.HIGH].color).toBe('error') - }) - - it('should return "warning" when the severity is "LOW', () => { - expect(mapSeverityComponentProps[SecuritySeverity.LOW].color).toBe('warning') - }) - - it('should return "info" when the severity is "NONE', () => { - expect(mapSeverityComponentProps[SecuritySeverity.NONE].color).toBe('info') - }) - }) - - describe('mapRisk', () => { - it('should return "Critical risk" when the severity is "HIGH', () => { - expect(mapSeverityComponentProps[SecuritySeverity.HIGH].label).toBe('High issue') - }) - - it('should return "Low risk" when the severity is "LOW', () => { - expect(mapSeverityComponentProps[SecuritySeverity.LOW].label).toBe('Low issue') - }) - - it('should return "No issues found" when the severity is "NONE', () => { - expect(mapSeverityComponentProps[SecuritySeverity.NONE].label).toBe('No issues found') - }) - }) - - describe('mapAction', () => { - it('should return "Reject this transaction" when the severity is "HIGH', () => { - expect(mapSeverityComponentProps[SecuritySeverity.HIGH].action).toBe('Reject this transaction') - }) - - it('should return "Review before processing" when the severity is "LOW', () => { - expect(mapSeverityComponentProps[SecuritySeverity.LOW].action).toBe('Review before processing') - }) - - it('should return undefined when the severity is "NONE', () => { - expect(mapSeverityComponentProps[SecuritySeverity.NONE].action).toBe(undefined) - }) - }) -}) diff --git a/src/components/tx/security/shared/SecurityWarnings/index.tsx b/src/components/tx/security/shared/SecurityWarnings/index.tsx deleted file mode 100644 index f5966d3006..0000000000 --- a/src/components/tx/security/shared/SecurityWarnings/index.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { - Alert, - type AlertColor, - SvgIcon, - Typography, - Box, - Grid, - Checkbox, - Accordion, - AccordionSummary, - AccordionDetails, - FormControlLabel, - List, - ListItem, -} from '@mui/material' - -import { SecuritySeverity } from '@/services/security/modules/types' -import AlertIcon from '@/public/images/notifications/alert.svg' - -import css from './styles.module.css' -import { LoadingLabel } from '../LoadingLabel' -import { type Dispatch, type ReactElement, type SetStateAction, useCallback } from 'react' -import RedefineLogo from '@/public/images/transactions/redefine.svg' -import RedefineLogoDark from '@/public/images/transactions/redefine-dark-mode.svg' - -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import Track from '@/components/common/Track' -import { MODALS_EVENTS } from '@/services/analytics' -import { useDarkMode } from '@/hooks/useDarkMode' - -type SecurityWarningProps = { - color: AlertColor - label: string - action?: string -} - -const ACTION_REJECT = 'Reject this transaction' -const ACTION_REVIEW = 'Review before processing' - -export const mapSeverityComponentProps: Record<SecuritySeverity, SecurityWarningProps> = { - [SecuritySeverity.CRITICAL]: { - action: ACTION_REJECT, - color: 'error', - label: 'Critical issue', - }, - [SecuritySeverity.HIGH]: { - action: ACTION_REJECT, - color: 'error', - label: 'High issue', - }, - [SecuritySeverity.MEDIUM]: { - action: ACTION_REVIEW, - color: 'warning', - label: 'Medium issue', - }, - [SecuritySeverity.LOW]: { - action: ACTION_REVIEW, - color: 'warning', - label: 'Low issue', - }, - [SecuritySeverity.NONE]: { - color: 'info', - label: 'No issues found', - }, -} - -export const SecurityHint = ({ severity, warnings }: { severity: SecuritySeverity; warnings: string[] }) => { - const severityProps = mapSeverityComponentProps[severity] - const pluralizedLabel = `${severityProps.label}${warnings.length > 1 ? 's' : ''}` - return ( - <> - <Alert - className={css.hint} - severity={severityProps.color} - sx={{ bgcolor: ({ palette }) => palette[severityProps.color].background }} - icon={ - <SvgIcon - component={AlertIcon} - inheritViewBox - color={severityProps.color} - sx={{ - '& path': { - fill: ({ palette }) => palette[severityProps.color].main, - }, - }} - /> - } - > - {severity !== SecuritySeverity.NONE && <Typography variant="h5">{pluralizedLabel}</Typography>} - <Box display="flex" flexDirection="column" gap={2}> - <List sx={{ listStyle: 'disc', pl: 2, '& li:last-child': { m: 0 } }}> - {warnings.map((warning) => ( - <ListItem key={warning} disablePadding sx={{ display: 'list-item', mb: 1 }}> - <Typography variant="body2">{warning}</Typography> - </ListItem> - ))} - </List> - </Box> - </Alert> - </> - ) -} - -export const SecurityWarning = ({ - severity, - isLoading, - error, - children, - needsConfirmation, - isConfirmed, - setIsConfirmed, -}: { - severity: SecuritySeverity | undefined - isLoading: boolean - error: Error | undefined - children: ReactElement - needsConfirmation: boolean - isConfirmed: boolean - setIsConfirmed: Dispatch<SetStateAction<boolean>> -}) => { - const isDarkMode = useDarkMode() - const severityProps = severity !== undefined ? mapSeverityComponentProps[severity] : undefined - - const toggleConfirmation = useCallback(() => { - setIsConfirmed((prev) => !prev) - }, [setIsConfirmed]) - - return ( - <Box className={css.wrapperBox}> - <Accordion className={css.verdictBox}> - <AccordionSummary sx={{ mb: 0 }} expandIcon={<ExpandMoreIcon />}> - <Grid container direction="row" justifyContent="space-between" alignItems="center"> - <Grid item> - <Typography fontWeight={700} variant="subtitle1"> - Scan for risks - </Typography> - - <Typography - variant="caption" - color="text.secondary" - display="flex" - flexDirection="row" - gap={1} - alignItems="center" - position="relative" - > - Powered by{' '} - <SvgIcon - inheritViewBox - sx={{ height: '40px', width: '52px', position: 'absolute', right: '-20px' }} - component={isDarkMode ? RedefineLogoDark : RedefineLogo} - /> - </Typography> - </Grid> - - {isLoading ? ( - <LoadingLabel /> - ) : severityProps ? ( - <Typography variant="body2" fontWeight={700} color={`${severityProps.color}.main`}> - {severityProps.label} - </Typography> - ) : error ? ( - <Typography variant="body2" fontWeight={700} color="error"> - {error.message} - </Typography> - ) : null} - </Grid> - </AccordionSummary> - - <AccordionDetails>{children}</AccordionDetails> - </Accordion> - - {needsConfirmation && ( - <Box pl={2}> - <Track {...MODALS_EVENTS.ACCEPT_RISK}> - <FormControlLabel - label="I understand the risks and would like to continue this transaction" - control={<Checkbox checked={isConfirmed} onChange={toggleConfirmation} />} - /> - </Track> - </Box> - )} - </Box> - ) -} diff --git a/src/components/tx/security/shared/SecurityWarnings/styles.module.css b/src/components/tx/security/shared/SecurityWarnings/styles.module.css deleted file mode 100644 index 697205454c..0000000000 --- a/src/components/tx/security/shared/SecurityWarnings/styles.module.css +++ /dev/null @@ -1,60 +0,0 @@ -.hint { - padding: var(--space-2); - border: unset; -} - -.hint :global .MuiAlert-icon { - padding: 0; -} - -.hint :global .MuiAlert-message { - padding: 0; -} - -.warning { - padding: var(--space-2) var(--space-4); - background-color: unset; -} - -.warning :global .MuiAlert-message { - padding: 0; -} - -.checkbox { - margin: var(--space-2) 0; -} - -.checkbox :global .MuiCheckbox-root { - padding: 0 12px; -} - -.verdictBox { - border: none; - border-radius: 6px; - border-bottom: 1px solid var(--color-border-light); -} - -.verdictBox :global .Mui-expanded.MuiAccordionSummary-root { - background-color: var(--color-background-paper); - border-bottom: 1px solid var(--color-border-light); -} - -.verdictBox :global .MuiAccordionSummary-root:hover { - background-color: var(--color-background-paper); -} - -.verdictBox:hover { - border-color: var(--color-border-light); -} - -.wrapperBox :global .MuiAccordion-root.Mui-expanded { - border-color: var(--color-border-light) !important; -} - -.wrapperBox { - border-radius: 6px; - border: 1px solid var(--color-border-light); - padding: 0; - margin-top: var(--space-2); - background-color: var(--color-background-main); -} diff --git a/src/components/tx/security/TransactionSecurityContext/index.tsx b/src/components/tx/security/shared/TxSecurityContext.tsx similarity index 64% rename from src/components/tx/security/TransactionSecurityContext/index.tsx rename to src/components/tx/security/shared/TxSecurityContext.tsx index 58d54f5d6d..f1840302fc 100644 --- a/src/components/tx/security/TransactionSecurityContext/index.tsx +++ b/src/components/tx/security/shared/TxSecurityContext.tsx @@ -1,10 +1,10 @@ import { type RedefineModuleResponse } from '@/services/security/modules/RedefineModule' import { SecuritySeverity } from '@/services/security/modules/types' -import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' -import { createContext, type Dispatch, type SetStateAction, useMemo, useState } from 'react' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { createContext, type Dispatch, type SetStateAction, useContext, useMemo, useState } from 'react' import { useRedefine } from '../redefine/useRedefine' -export const TransactionSecurityContext = createContext<{ +export const TxSecurityContext = createContext<{ warnings: NonNullable<RedefineModuleResponse['issues']> simulationUuid: string | undefined balanceChange: RedefineModuleResponse['balanceChange'] @@ -14,6 +14,8 @@ export const TransactionSecurityContext = createContext<{ needsRiskConfirmation: boolean isRiskConfirmed: boolean setIsRiskConfirmed: Dispatch<SetStateAction<boolean>> + isRiskIgnored: boolean + setIsRiskIgnored: Dispatch<SetStateAction<boolean>> }>({ warnings: [], simulationUuid: undefined, @@ -24,17 +26,15 @@ export const TransactionSecurityContext = createContext<{ needsRiskConfirmation: false, isRiskConfirmed: false, setIsRiskConfirmed: () => {}, + isRiskIgnored: false, + setIsRiskIgnored: () => {}, }) -export const TransactionSecurityProvider = ({ - children, - safeTx, -}: { - children: JSX.Element - safeTx: SafeTransaction | undefined -}) => { +export const TxSecurityProvider = ({ children }: { children: JSX.Element }) => { + const { safeTx } = useContext(SafeTxContext) const [redefineResponse, redefineError, redefineLoading] = useRedefine(safeTx) const [isRiskConfirmed, setIsRiskConfirmed] = useState(false) + const [isRiskIgnored, setIsRiskIgnored] = useState(false) const providedValue = useMemo( () => ({ @@ -47,9 +47,11 @@ export const TransactionSecurityProvider = ({ needsRiskConfirmation: !!redefineResponse && redefineResponse.severity >= SecuritySeverity.HIGH, isRiskConfirmed, setIsRiskConfirmed, + isRiskIgnored: isRiskIgnored && !isRiskConfirmed, + setIsRiskIgnored, }), - [isRiskConfirmed, redefineError, redefineLoading, redefineResponse], + [isRiskConfirmed, isRiskIgnored, redefineError, redefineLoading, redefineResponse], ) - return <TransactionSecurityContext.Provider value={providedValue}>{children}</TransactionSecurityContext.Provider> + return <TxSecurityContext.Provider value={providedValue}>{children}</TxSecurityContext.Provider> } diff --git a/src/components/tx/security/shared/styles.module.css b/src/components/tx/security/shared/styles.module.css new file mode 100644 index 0000000000..ddbb7d675b --- /dev/null +++ b/src/components/tx/security/shared/styles.module.css @@ -0,0 +1,18 @@ +.wrapper { + display: flex; + justify-content: space-between; + padding: var(--space-1) var(--space-2); + border-width: 1px; +} + +.poweredBy { + color: var(--color-text-secondary); + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + +.result { + display: inline-flex; + align-items: center; +} diff --git a/src/components/tx/TxSimulation/__tests__/useSimulation.test.ts b/src/components/tx/security/tenderly/__tests__/useSimulation.test.ts similarity index 95% rename from src/components/tx/TxSimulation/__tests__/useSimulation.test.ts rename to src/components/tx/security/tenderly/__tests__/useSimulation.test.ts index ffde6952f5..97b33bd226 100644 --- a/src/components/tx/TxSimulation/__tests__/useSimulation.test.ts +++ b/src/components/tx/security/tenderly/__tests__/useSimulation.test.ts @@ -1,9 +1,9 @@ import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { act, renderHook, waitFor } from '@/tests/test-utils' -import { useSimulation } from '@/components/tx/TxSimulation/useSimulation' -import * as utils from '@/components/tx/TxSimulation/utils' -import { FETCH_STATUS, type TenderlySimulation } from '@/components/tx/TxSimulation/types' +import { useSimulation } from '@/components/tx/security/tenderly/useSimulation' +import * as utils from '@/components/tx/security/tenderly/utils' +import { FETCH_STATUS, type TenderlySimulation } from '@/components/tx/security/tenderly/types' const setupFetchStub = (data: any) => (_url: string) => { return Promise.resolve({ @@ -46,7 +46,7 @@ describe('useSimulation()', () => { const mockFetch = jest.spyOn(global, 'fetch') - mockFetch.mockImplementation(() => Promise.reject({ message: '404 not found' })) + mockFetch.mockImplementation(() => Promise.reject(new Error('404 not found'))) jest.spyOn(utils, 'getSimulationPayload').mockImplementation(() => Promise.resolve({ @@ -80,7 +80,6 @@ describe('useSimulation()', () => { chainId, } as SafeInfo, executionOwner: safeAddress, - canExecute: true, }), ) @@ -150,7 +149,6 @@ describe('useSimulation()', () => { chainId, } as SafeInfo, executionOwner: safeAddress, - canExecute: true, }), ) @@ -221,7 +219,6 @@ describe('useSimulation()', () => { chainId, } as SafeInfo, executionOwner: safeAddress, - canExecute: false, }), ) diff --git a/src/components/tx/TxSimulation/__tests__/utils.test.ts b/src/components/tx/security/tenderly/__tests__/utils.test.ts similarity index 97% rename from src/components/tx/TxSimulation/__tests__/utils.test.ts rename to src/components/tx/security/tenderly/__tests__/utils.test.ts index ec63d95d7d..10fee74971 100644 --- a/src/components/tx/TxSimulation/__tests__/utils.test.ts +++ b/src/components/tx/security/tenderly/__tests__/utils.test.ts @@ -1,7 +1,11 @@ import type { MetaTransactionData, SafeTransaction } from '@safe-global/safe-core-sdk-types' import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { BigNumber, ethers } from 'ethers' -import { getSimulationPayload, NONCE_STORAGE_POSITION, THRESHOLD_STORAGE_POSITION } from '../utils' +import { + getSimulationPayload, + NONCE_STORAGE_POSITION, + THRESHOLD_STORAGE_POSITION, +} from '@/components/tx/security/tenderly/utils' import * as safeContracts from '@/services/contracts/safeContracts' import { getMultiSendCallOnlyDeployment, getSafeSingletonDeployment } from '@safe-global/safe-deployments' import EthSafeTransaction from '@safe-global/safe-core-sdk/dist/src/utils/transactions/SafeTransaction' @@ -71,7 +75,6 @@ describe('simulation utils', () => { }) const tenderlyPayload = await getSimulationPayload({ - canExecute: true, executionOwner: ownerAddress, gasLimit: 50_000, safe: mockSafeInfo as SafeInfo, @@ -140,7 +143,6 @@ describe('simulation utils', () => { mockTx.addSignature(generatePreValidatedSignature(otherOwnerAddress2)) const tenderlyPayload = await getSimulationPayload({ - canExecute: true, executionOwner: ownerAddress, gasLimit: 50_000, safe: mockSafeInfo as SafeInfo, @@ -181,7 +183,6 @@ describe('simulation utils', () => { mockTx.addSignature(generatePreValidatedSignature(otherOwnerAddress1)) const tenderlyPayload = await getSimulationPayload({ - canExecute: false, executionOwner: ownerAddress, safe: mockSafeInfo as SafeInfo, transactions: mockTx, @@ -226,7 +227,6 @@ describe('simulation utils', () => { mockTx.addSignature(generatePreValidatedSignature(otherOwnerAddress1)) const tenderlyPayload = await getSimulationPayload({ - canExecute: true, executionOwner: ownerAddress, gasLimit: 50_000, safe: mockSafeInfo as SafeInfo, @@ -265,7 +265,6 @@ describe('simulation utils', () => { }) const tenderlyPayload = await getSimulationPayload({ - canExecute: false, executionOwner: ownerAddress, gasLimit: 50_000, safe: mockSafeInfo as SafeInfo, @@ -315,7 +314,6 @@ describe('simulation utils', () => { ] const tenderlyPayload = await getSimulationPayload({ - canExecute: true, executionOwner: ownerAddress, safe: mockSafeInfo as SafeInfo, transactions: mockTxs, diff --git a/src/components/tx/security/tenderly/index.tsx b/src/components/tx/security/tenderly/index.tsx new file mode 100644 index 0000000000..ab1c75b261 --- /dev/null +++ b/src/components/tx/security/tenderly/index.tsx @@ -0,0 +1,202 @@ +import { Alert, Button, Paper, SvgIcon, Tooltip, Typography } from '@mui/material' +import { useContext, useEffect } from 'react' +import type { ReactElement } from 'react' + +import useSafeInfo from '@/hooks/useSafeInfo' +import useWallet from '@/hooks/wallets/useWallet' +import CheckIcon from '@/public/images/common/check.svg' +import CloseIcon from '@/public/images/common/close.svg' +import { useDarkMode } from '@/hooks/useDarkMode' +import CircularProgress from '@mui/material/CircularProgress' +import ExternalLink from '@/components/common/ExternalLink' +import { useCurrentChain } from '@/hooks/useChains' +import { FETCH_STATUS } from '@/components/tx/security/tenderly/types' +import { isTxSimulationEnabled } from '@/components/tx/security/tenderly/utils' +import type { SimulationTxParams } from '@/components/tx/security/tenderly/utils' +import type { TenderlySimulation } from '@/components/tx/security/tenderly/types' + +import css from './styles.module.css' +import sharedCss from '@/components/tx/security/shared/styles.module.css' +import { TxInfoContext } from '@/components/tx-flow/TxInfoProvider' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import InfoIcon from '@/public/images/notifications/info.svg' + +export type TxSimulationProps = { + transactions?: SimulationTxParams['transactions'] + gasLimit?: number + disabled: boolean +} + +const getCallTraceErrors = (simulation?: TenderlySimulation) => { + if (!simulation || !simulation.simulation.status) { + return [] + } + + return simulation.transaction.call_trace.filter((call) => call.error) +} + +// TODO: Investigate resetting on gasLimit change as we are not simulating with the gasLimit of the tx +// otherwise remove all usage of gasLimit in simulation. Note: this was previously being done. +const TxSimulationBlock = ({ transactions, disabled, gasLimit }: TxSimulationProps): ReactElement => { + const { safe } = useSafeInfo() + const wallet = useWallet() + const isDarkMode = useDarkMode() + const { safeTx } = useContext(SafeTxContext) + const { + simulation: { simulateTransaction, simulationRequestStatus, resetSimulation }, + } = useContext(TxInfoContext) + + const isLoading = simulationRequestStatus === FETCH_STATUS.LOADING + const isSuccess = simulationRequestStatus === FETCH_STATUS.SUCCESS + const isError = simulationRequestStatus === FETCH_STATUS.ERROR + + const handleSimulation = async () => { + if (!wallet) { + return + } + + simulateTransaction({ + safe, + executionOwner: wallet.address, + transactions, + gasLimit, + } as SimulationTxParams) + } + + // Reset simulation if safeTx changes + useEffect(() => { + resetSimulation() + }, [safeTx, resetSimulation]) + + return ( + <Paper variant="outlined" className={sharedCss.wrapper}> + <div className={css.wrapper}> + <Typography variant="body2" fontWeight={700}> + Run a simulation + <Tooltip + title="This transaction can be simulated before execution to ensure that it will be succeed, generating a detailed report of the transaction execution." + arrow + placement="top" + > + <span> + <SvgIcon + component={InfoIcon} + inheritViewBox + color="border" + fontSize="small" + sx={{ + verticalAlign: 'middle', + ml: 0.5, + }} + /> + </span> + </Tooltip> + </Typography> + <Typography variant="caption" className={sharedCss.poweredBy}> + Powered by{' '} + <img + src={isDarkMode ? '/images/transactions/tenderly-light.svg' : '/images/transactions/tenderly-dark.svg'} + alt="Tenderly" + width="65px" + height="15px" + /> + </Typography> + </div> + + <div className={sharedCss.result}> + {isLoading ? ( + <CircularProgress + size={22} + sx={{ + color: ({ palette }) => palette.text.secondary, + }} + /> + ) : isSuccess ? ( + <Typography variant="body2" color="success.main" className={sharedCss.result}> + <SvgIcon component={CheckIcon} inheritViewBox fontSize="small" sx={{ verticalAlign: 'middle', mr: 1 }} /> + Success + </Typography> + ) : isError ? ( + <Typography variant="body2" color="error.main" className={sharedCss.result}> + <SvgIcon component={CloseIcon} inheritViewBox fontSize="small" sx={{ verticalAlign: 'middle', mr: 1 }} /> + Error + </Typography> + ) : ( + <Button + variant="outlined" + size="small" + className={css.simulate} + onClick={handleSimulation} + disabled={!transactions || disabled} + > + Simulate + </Button> + )} + </div> + </Paper> + ) +} + +export const TxSimulation = (props: TxSimulationProps): ReactElement | null => { + const chain = useCurrentChain() + + if (!chain || !isTxSimulationEnabled(chain)) { + return null + } + + return <TxSimulationBlock {...props} /> +} + +export const TxSimulationMessage = () => { + const { + simulation: { simulationRequestStatus, simulationLink, simulation, requestError }, + } = useContext(TxInfoContext) + + const isFinished = simulationRequestStatus === FETCH_STATUS.SUCCESS || simulationRequestStatus === FETCH_STATUS.ERROR + + if (!isFinished) { + return null + } + + const isSuccess = simulation?.simulation.status + // Safe can emit failure event even though Tenderly simulation succeeds + const isCallTraceError = isSuccess && getCallTraceErrors(simulation).length > 0 + const isError = simulationRequestStatus === FETCH_STATUS.ERROR + + if (!isSuccess || isError || isCallTraceError) { + return ( + <Alert severity="error" sx={{ border: 'unset' }}> + <Typography variant="body2" fontWeight={700}> + Simulation failed + </Typography> + {requestError ? ( + <Typography color="error"> + An unexpected error occurred during simulation: <b>{requestError}</b>. + </Typography> + ) : ( + <Typography> + {isCallTraceError ? ( + <>The transaction failed during the simulation.</> + ) : ( + <> + The transaction failed during the simulation throwing error{' '} + <b>{simulation?.transaction.error_message}</b> in the contract at{' '} + <b>{simulation?.transaction.error_info?.address}</b>. + </> + )}{' '} + Full simulation report is available <ExternalLink href={simulationLink}>on Tenderly</ExternalLink>. + </Typography> + )} + </Alert> + ) + } + + return ( + <Alert severity="info" sx={{ border: 'unset' }}> + <Typography variant="body2" fontWeight={700}> + Simulation successful + </Typography> + Full simulation report is available <ExternalLink href={simulationLink}>on Tenderly</ExternalLink>. + </Alert> + ) +} diff --git a/src/components/tx/security/tenderly/styles.module.css b/src/components/tx/security/tenderly/styles.module.css new file mode 100644 index 0000000000..464186db25 --- /dev/null +++ b/src/components/tx/security/tenderly/styles.module.css @@ -0,0 +1,8 @@ +.simulate { + margin: 0; + padding: calc(var(--space-1) / 2) var(--space-2); +} + +.wrapper { + line-height: 1; +} diff --git a/src/components/tx/TxSimulation/types.ts b/src/components/tx/security/tenderly/types.ts similarity index 100% rename from src/components/tx/TxSimulation/types.ts rename to src/components/tx/security/tenderly/types.ts diff --git a/src/components/tx/TxSimulation/useSimulation.ts b/src/components/tx/security/tenderly/useSimulation.ts similarity index 90% rename from src/components/tx/TxSimulation/useSimulation.ts rename to src/components/tx/security/tenderly/useSimulation.ts index 1f101492fd..286d8cdcef 100644 --- a/src/components/tx/TxSimulation/useSimulation.ts +++ b/src/components/tx/security/tenderly/useSimulation.ts @@ -1,12 +1,13 @@ import { useCallback, useMemo, useState } from 'react' -import { getSimulation, getSimulationLink } from '@/components/tx/TxSimulation/utils' -import { FETCH_STATUS, type TenderlySimulation } from '@/components/tx/TxSimulation/types' -import { getSimulationPayload, type SimulationTxParams } from '@/components/tx/TxSimulation/utils' +import { getSimulation, getSimulationLink } from '@/components/tx/security/tenderly/utils' +import { FETCH_STATUS, type TenderlySimulation } from '@/components/tx/security/tenderly/types' +import { getSimulationPayload, type SimulationTxParams } from '@/components/tx/security/tenderly/utils' import { useAppSelector } from '@/store' import { selectTenderly } from '@/store/settingsSlice' +import { asError } from '@/services/exceptions/utils' -type UseSimulationReturn = +export type UseSimulationReturn = | { simulationRequestStatus: FETCH_STATUS.NOT_ASKED | FETCH_STATUS.ERROR | FETCH_STATUS.LOADING simulation: undefined @@ -53,7 +54,7 @@ export const useSimulation = (): UseSimulationReturn => { } catch (error) { console.error(error) - setRequestError((error as Error).message) + setRequestError(asError(error).message) setSimulationRequestStatus(FETCH_STATUS.ERROR) } }, diff --git a/src/components/tx/TxSimulation/utils.ts b/src/components/tx/security/tenderly/utils.ts similarity index 98% rename from src/components/tx/TxSimulation/utils.ts rename to src/components/tx/security/tenderly/utils.ts index 7b779a8d40..264cfb8e32 100644 --- a/src/components/tx/TxSimulation/utils.ts +++ b/src/components/tx/security/tenderly/utils.ts @@ -10,7 +10,7 @@ import { } from '@/services/contracts/safeContracts' import { TENDERLY_SIMULATE_ENDPOINT_URL, TENDERLY_ORG_NAME, TENDERLY_PROJECT_NAME } from '@/config/constants' import { FEATURES, hasFeature } from '@/utils/chains' -import type { StateObject, TenderlySimulatePayload, TenderlySimulation } from '@/components/tx/TxSimulation/types' +import type { StateObject, TenderlySimulatePayload, TenderlySimulation } from '@/components/tx/security/tenderly/types' import { _getWeb3 } from '@/hooks/wallets/web3' import { hexZeroPad } from 'ethers/lib/utils' import { BigNumber } from 'ethers' @@ -67,7 +67,6 @@ type SingleTransactionSimulationParams = { executionOwner: string transactions: SafeTransaction gasLimit?: number - canExecute: boolean } type MultiSendTransactionSimulationParams = { @@ -75,7 +74,6 @@ type MultiSendTransactionSimulationParams = { executionOwner: string transactions: MetaTransactionData[] gasLimit?: number - canExecute: boolean } export type SimulationTxParams = SingleTransactionSimulationParams | MultiSendTransactionSimulationParams diff --git a/src/components/tx/security/redefine/useDelegateCallModule.ts b/src/components/tx/security/useDelegateCallModule.ts similarity index 96% rename from src/components/tx/security/redefine/useDelegateCallModule.ts rename to src/components/tx/security/useDelegateCallModule.ts index 4a70f17e82..a838a64403 100644 --- a/src/components/tx/security/redefine/useDelegateCallModule.ts +++ b/src/components/tx/security/useDelegateCallModule.ts @@ -8,6 +8,7 @@ import type { SecurityResponse } from '@/services/security/modules/types' const DelegateCallModuleInstance = new DelegateCallModule() +// TODO: Not being used right now export const useDelegateCallModule = (safeTransaction: SafeTransaction | undefined) => { const { safe, safeLoaded } = useSafeInfo() diff --git a/src/components/tx/security/redefine/useRecipientModule.ts b/src/components/tx/security/useRecipientModule.ts similarity index 97% rename from src/components/tx/security/redefine/useRecipientModule.ts rename to src/components/tx/security/useRecipientModule.ts index 81810227f8..25e7bdc31a 100644 --- a/src/components/tx/security/redefine/useRecipientModule.ts +++ b/src/components/tx/security/useRecipientModule.ts @@ -11,6 +11,7 @@ import type { SecurityResponse } from '@/services/security/modules/types' const RecipientAddressModuleInstance = new RecipientAddressModule() +// TODO: Not being used right now export const useRecipientModule = (safeTransaction: SafeTransaction | undefined) => { const { safe, safeLoaded } = useSafeInfo() const web3 = useWeb3() diff --git a/src/components/welcome/styles.module.css b/src/components/welcome/styles.module.css index 5a9ca361c5..109c8246e2 100644 --- a/src/components/welcome/styles.module.css +++ b/src/components/welcome/styles.module.css @@ -63,7 +63,7 @@ padding: 0; } -@media (max-width: 900px) { +@media (max-width: 899.95px) { .sidebar :global .MuiPaper-root { height: 100%; } @@ -85,7 +85,7 @@ } } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .content { padding: var(--space-4); } diff --git a/src/config/constants.ts b/src/config/constants.ts index 771e8ed57e..e1217ea980 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -79,6 +79,7 @@ export const HelpCenterArticle = { SAFE_SETUP: `${HELP_CENTER_URL}/en/articles/40835-what-safe-setup-should-i-use`, SIGNED_MESSAGES: `${HELP_CENTER_URL}/en/articles/40783-what-are-signed-messages`, SPAM_TOKENS: `${HELP_CENTER_URL}/en/articles/40784-default-token-list-local-hiding-of-spam-tokens`, + SPENDING_LIMITS: `${HELP_CENTER_URL}/en/articles/40842-set-up-and-use-spending-limits`, TRANSACTION_GUARD: `${HELP_CENTER_URL}/en/articles/40809-what-is-a-transaction-guard`, UNEXPECTED_DELEGATE_CALL: `${HELP_CENTER_URL}/en/articles/40794-why-do-i-see-an-unexpected-delegate-call-warning-in-my-transaction`, } as const @@ -93,3 +94,4 @@ export const IS_OFFICIAL_HOST = process.env.NEXT_PUBLIC_IS_OFFICIAL_HOST || fals // Risk mitigation (Redefine) export const REDEFINE_SIMULATION_URL = 'https://dashboard.redefine.net/reports/' export const REDEFINE_API = process.env.NEXT_PUBLIC_REDEFINE_API +export const REDEFINE_ARTICLE = 'https://safe.mirror.xyz/rInLWZwD_sf7enjoFerj6FIzCYmVMGrrV8Nhg4THdwI' diff --git a/src/hooks/__tests__/useAsync.test.ts b/src/hooks/__tests__/useAsync.test.ts index 05c24fd773..1e167ba39c 100644 --- a/src/hooks/__tests__/useAsync.test.ts +++ b/src/hooks/__tests__/useAsync.test.ts @@ -35,7 +35,7 @@ describe('useAsync hook', () => { expect(result.current).toEqual([undefined, undefined, true]) await waitFor(() => { - expect(result.current).toEqual([undefined, 'test', false]) + expect(result.current).toEqual([undefined, new Error('test'), false]) }) }) diff --git a/src/hooks/__tests__/useBatchedTxs.test.ts b/src/hooks/__tests__/useBatchedTxs.test.ts index 5b8d6c33dd..67480d5033 100644 --- a/src/hooks/__tests__/useBatchedTxs.test.ts +++ b/src/hooks/__tests__/useBatchedTxs.test.ts @@ -1,67 +1,7 @@ -import type { - AddressEx, - MultisigExecutionInfo, - Transaction, - TransactionInfo, - TransactionListItem, - TransactionSummary, - TransferInfo, -} from '@safe-global/safe-gateway-typescript-sdk' -import { - ConflictType, - DetailedExecutionInfoType, - TransactionInfoType, - TransactionListItemType, - TransactionStatus, - TransactionTokenType, - TransferDirection, -} from '@safe-global/safe-gateway-typescript-sdk' +import type { MultisigExecutionInfo, Transaction, TransactionListItem } from '@safe-global/safe-gateway-typescript-sdk' +import { ConflictType, TransactionListItemType } from '@safe-global/safe-gateway-typescript-sdk' import { getBatchableTransactions } from '@/hooks/useBatchedTxs' - -const mockAddressEx: AddressEx = { - value: 'string', -} - -const mockTransferInfo: TransferInfo = { - type: TransactionTokenType.ERC20, - tokenAddress: 'string', - value: 'string', -} - -const mockTxInfo: TransactionInfo = { - type: TransactionInfoType.TRANSFER, - sender: mockAddressEx, - recipient: mockAddressEx, - direction: TransferDirection.OUTGOING, - transferInfo: mockTransferInfo, -} - -const defaultTx: TransactionSummary = { - id: '', - timestamp: 0, - txInfo: mockTxInfo, - txStatus: TransactionStatus.AWAITING_CONFIRMATIONS, - executionInfo: { - type: DetailedExecutionInfoType.MULTISIG, - nonce: 1, - confirmationsRequired: 2, - confirmationsSubmitted: 2, - }, -} - -const getMockTx = ({ nonce }: { nonce?: number }): Transaction => { - return { - transaction: { - ...defaultTx, - executionInfo: { - ...defaultTx.executionInfo, - nonce: nonce ?? (defaultTx.executionInfo as MultisigExecutionInfo).nonce, - } as MultisigExecutionInfo, - }, - type: TransactionListItemType.TRANSACTION, - conflictType: ConflictType.NONE, - } -} +import { defaultTx, getMockTx } from '@/tests/mocks/transactions' describe('getBatchableTransactions', () => { it('should return an empty array if no transactions are passed', () => { diff --git a/src/hooks/__tests__/usePreviousNonces.test.ts b/src/hooks/__tests__/usePreviousNonces.test.ts new file mode 100644 index 0000000000..3115a03cb7 --- /dev/null +++ b/src/hooks/__tests__/usePreviousNonces.test.ts @@ -0,0 +1,29 @@ +import { _getUniqueQueuedTxs } from '@/hooks/usePreviousNonces' +import { getMockTx } from '@/tests/mocks/transactions' + +describe('_getUniqueQueuedTxs', () => { + it('returns an empty array if input is undefined', () => { + const result = _getUniqueQueuedTxs() + + expect(result).toEqual([]) + }) + + it('returns an empty array if input is an empty array', () => { + const result = _getUniqueQueuedTxs({ results: [] }) + + expect(result).toEqual([]) + }) + + it('only returns one transaction per nonce', () => { + const mockTx = getMockTx({ nonce: 0 }) + const mockTx1 = getMockTx({ nonce: 1 }) + const mockTx2 = getMockTx({ nonce: 1 }) + + const mockPage = { + results: [mockTx, mockTx1, mockTx2], + } + const result = _getUniqueQueuedTxs(mockPage) + + expect(result.length).toEqual(2) + }) +}) diff --git a/src/hooks/coreSDK/useInitSafeCoreSDK.ts b/src/hooks/coreSDK/useInitSafeCoreSDK.ts index 6afb02d23f..a4d4a1059a 100644 --- a/src/hooks/coreSDK/useInitSafeCoreSDK.ts +++ b/src/hooks/coreSDK/useInitSafeCoreSDK.ts @@ -8,6 +8,7 @@ import { useAppDispatch } from '@/store' import { showNotification } from '@/store/notificationsSlice' import { useWeb3 } from '@/hooks/wallets/web3' import { parsePrefixedAddress, sameAddress } from '@/utils/addresses' +import { asError } from '@/services/exceptions/utils' export const useInitSafeCoreSDK = () => { const { safe, safeLoaded } = useSafeInfo() @@ -35,16 +36,17 @@ export const useInitSafeCoreSDK = () => { implementation: safe.implementation.value, }) .then(setSafeSDK) - .catch((e) => { + .catch((_e) => { + const e = asError(_e) dispatch( showNotification({ message: 'Please try connecting your wallet again.', groupKey: 'core-sdk-init-error', variant: 'error', - detailedMessage: (e as Error).message, + detailedMessage: e.message, }), ) - trackError(ErrorCodes._105, (e as Error).message) + trackError(ErrorCodes._105, e.message) }) }, [ address, diff --git a/src/hooks/messages/useSyncSafeMessageSigner.ts b/src/hooks/messages/useSyncSafeMessageSigner.ts index 4fed68b50b..17bf4a4aa4 100644 --- a/src/hooks/messages/useSyncSafeMessageSigner.ts +++ b/src/hooks/messages/useSyncSafeMessageSigner.ts @@ -1,3 +1,4 @@ +import { asError } from '@/services/exceptions/utils' import { dispatchPreparedSignature } from '@/services/safe-messages/safeMsgNotifications' import { dispatchSafeMsgProposal, dispatchSafeMsgConfirmation } from '@/services/safe-messages/safeMsgSender' import { type EIP712TypedData, type SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' @@ -56,7 +57,7 @@ const useSyncSafeMessageSigner = ( } } } catch (e) { - setSubmitError(e as Error) + setSubmitError(asError(e)) } }, [onboard, requestId, message, safe, decodedMessage, safeAppId, safeMessageHash, onClose]) diff --git a/src/hooks/safe-apps/useSafeAppFromManifest.ts b/src/hooks/safe-apps/useSafeAppFromManifest.ts index 3757c6cd76..015a9f5ee8 100644 --- a/src/hooks/safe-apps/useSafeAppFromManifest.ts +++ b/src/hooks/safe-apps/useSafeAppFromManifest.ts @@ -4,6 +4,7 @@ import { fetchSafeAppFromManifest } from '@/services/safe-apps/manifest' import useAsync from '@/hooks/useAsync' import { getEmptySafeApp } from '@/components/safe-apps/utils' import type { SafeAppDataWithPermissions } from '@/components/safe-apps/types' +import { asError } from '@/services/exceptions/utils' type UseSafeAppFromManifestReturnType = { safeApp: SafeAppDataWithPermissions @@ -19,7 +20,7 @@ const useSafeAppFromManifest = (appUrl: string, chainId: string): UseSafeAppFrom useEffect(() => { if (!error) return - logError(Errors._903, `${appUrl}, ${(error as Error).message}`) + logError(Errors._903, `${appUrl}, ${asError(error).message}`) }, [appUrl, error]) return { safeApp: data || emptyApp, isLoading } diff --git a/src/hooks/useAsync.ts b/src/hooks/useAsync.ts index 0883added3..494518a0e8 100644 --- a/src/hooks/useAsync.ts +++ b/src/hooks/useAsync.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { asError } from '@/services/exceptions/utils' export type AsyncResult<T> = [result: T | undefined, error: Error | undefined, loading: boolean] @@ -35,7 +36,7 @@ const useAsync = <T>( isCurrent && setData(val) }) .catch((err) => { - isCurrent && setError(err) + isCurrent && setError(asError(err)) }) .finally(() => { isCurrent && setLoading(false) diff --git a/src/hooks/useGasLimit.ts b/src/hooks/useGasLimit.ts index 3c14737a1b..bd0ae7e526 100644 --- a/src/hooks/useGasLimit.ts +++ b/src/hooks/useGasLimit.ts @@ -51,6 +51,7 @@ const useGasLimit = ( const walletAddress = wallet?.address const isOwner = useIsSafeOwner() const currentChainId = useChainId() + const hasSafeTxGas = !!safeTx?.data?.safeTxGas const encodedSafeTx = useMemo<string>(() => { if (!safeTx || !safeSDK || !walletAddress) { @@ -77,17 +78,14 @@ const useGasLimit = ( .then((gasLimit) => { // Due to a bug in Nethermind estimation, we need to increment the gasLimit by 30% // when the safeTxGas is defined and not 0. Currently Nethermind is used only for Gnosis Chain. - if (currentChainId === chains.gno) { + if (currentChainId === chains.gno && hasSafeTxGas) { const incrementPercentage = 30 // value defined in %, ex. 30% - const isSafeTxGasSetAndNotZero = !!safeTx?.data?.safeTxGas - if (isSafeTxGasSetAndNotZero) { - return incrementByPercentage(gasLimit, incrementPercentage) - } + return incrementByPercentage(gasLimit, incrementPercentage) } return gasLimit }) - }, [currentChainId, safeAddress, safeTx, walletAddress, encodedSafeTx, web3, operationType]) + }, [currentChainId, safeAddress, hasSafeTxGas, walletAddress, encodedSafeTx, web3, operationType]) useEffect(() => { if (gasLimitError) { diff --git a/src/hooks/useGasPrice.ts b/src/hooks/useGasPrice.ts index ca53019e13..4ec4b94274 100644 --- a/src/hooks/useGasPrice.ts +++ b/src/hooks/useGasPrice.ts @@ -7,6 +7,7 @@ import useIntervalCounter from './useIntervalCounter' import { useWeb3 } from '../hooks/wallets/web3' import { Errors, logError } from '@/services/exceptions' import { FEATURES, hasFeature } from '@/utils/chains' +import { asError } from '@/services/exceptions/utils' // Update gas fees every 20 seconds const REFRESH_DELAY = 20e3 @@ -37,8 +38,8 @@ const getGasPrice = async (gasPriceConfigs: GasPrice): Promise<BigNumber | undef if (config.type == GAS_PRICE_TYPE.ORACLE) { try { return await fetchGasOracle(config) - } catch (err) { - error = err as Error + } catch (_err) { + error = asError(_err) logError(Errors._611, error.message) // Continue to the next oracle continue diff --git a/src/components/tx/modals/TokenTransferModal/useIsSafeTokenPaused.ts b/src/hooks/useIsSafeTokenPaused.ts similarity index 100% rename from src/components/tx/modals/TokenTransferModal/useIsSafeTokenPaused.ts rename to src/hooks/useIsSafeTokenPaused.ts diff --git a/src/hooks/useMasterCopies.ts b/src/hooks/useMasterCopies.ts index bc47e67e9e..aa69def055 100644 --- a/src/hooks/useMasterCopies.ts +++ b/src/hooks/useMasterCopies.ts @@ -36,7 +36,7 @@ export const useMasterCopies = () => { const res = await getMasterCopies(chainId) return res.map(extractMasterCopyInfo) } catch (error) { - logError(Errors._619, (error as Error).message) + logError(Errors._619, error) } } return useAsync(fetchMasterCopies, [chainId]) diff --git a/src/hooks/usePreviousNonces.ts b/src/hooks/usePreviousNonces.ts new file mode 100644 index 0000000000..ae7ef9e635 --- /dev/null +++ b/src/hooks/usePreviousNonces.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react' +import { isMultisigExecutionInfo, isTransactionListItem } from '@/utils/transaction-guards' +import { uniqBy } from 'lodash' +import useTxQueue from '@/hooks/useTxQueue' +import { type TransactionListPage } from '@safe-global/safe-gateway-typescript-sdk' + +export const _getUniqueQueuedTxs = (page?: TransactionListPage) => { + if (!page) { + return [] + } + + const txs = page.results.filter(isTransactionListItem).map((item) => item.transaction) + + return uniqBy(txs, (tx) => { + return isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : '' + }) +} + +const usePreviousNonces = () => { + const { page } = useTxQueue() + + const previousNonces = useMemo(() => { + return _getUniqueQueuedTxs(page) + .map((tx) => (isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : undefined)) + .filter((nonce): nonce is number => !!nonce) + }, [page]) + + return previousNonces +} + +export default usePreviousNonces diff --git a/src/hooks/useSpendingLimitGas.ts b/src/hooks/useSpendingLimitGas.ts index d43906a33b..e21be1c74f 100644 --- a/src/hooks/useSpendingLimitGas.ts +++ b/src/hooks/useSpendingLimitGas.ts @@ -2,7 +2,7 @@ import type { BigNumber } from 'ethers' import { useWeb3 } from '@/hooks/wallets/web3' import { getSpendingLimitContract } from '@/services/contracts/spendingLimitContracts' import useAsync from '@/hooks/useAsync' -import type { SpendingLimitTxParams } from '@/components/tx/modals/TokenTransferModal/ReviewSpendingLimitTx' +import { type SpendingLimitTxParams } from '@/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx' import useChainId from '@/hooks/useChainId' const useSpendingLimitGas = (params: SpendingLimitTxParams) => { diff --git a/src/hooks/useWalletCanRelay.ts b/src/hooks/useWalletCanRelay.ts index 639890be77..9463e4e7a1 100644 --- a/src/hooks/useWalletCanRelay.ts +++ b/src/hooks/useWalletCanRelay.ts @@ -3,12 +3,12 @@ import useSafeInfo from '@/hooks/useSafeInfo' import useWallet from '@/hooks/wallets/useWallet' import { isSmartContractWallet } from '@/utils/wallets' import { Errors, logError } from '@/services/exceptions' -import { hasEnoughSignatures } from '@/utils/transactions' import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' const useWalletCanRelay = (tx: SafeTransaction | undefined) => { const { safe } = useSafeInfo() const wallet = useWallet() + const hasEnoughSignatures = tx && tx.signatures.size >= safe.threshold return useAsync(() => { if (!tx || !wallet) return @@ -17,13 +17,13 @@ const useWalletCanRelay = (tx: SafeTransaction | undefined) => { .then((isSCWallet) => { if (!isSCWallet) return true - return hasEnoughSignatures(tx, safe) + return hasEnoughSignatures }) .catch((err) => { logError(Errors._106, err.message) return false }) - }, [tx, wallet, safe.threshold]) + }, [hasEnoughSignatures, wallet]) } export default useWalletCanRelay diff --git a/src/hooks/wallets/useOnboard.ts b/src/hooks/wallets/useOnboard.ts index 85a1a12d04..ef8fef73d8 100644 --- a/src/hooks/wallets/useOnboard.ts +++ b/src/hooks/wallets/useOnboard.ts @@ -62,7 +62,7 @@ export const getConnectedWallet = (wallets: WalletState[]): ConnectedWallet | nu icon: primaryWallet.icon, } } catch (e) { - logError(Errors._106, (e as Error).message) + logError(Errors._106, e) return null } } @@ -127,7 +127,7 @@ export const connectWallet = async ( try { wallets = await onboard.connectWallet(options) } catch (e) { - logError(Errors._302, (e as Error).message) + logError(Errors._302, e) isConnecting = false return diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 3a2cb23567..d6612c4179 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -36,6 +36,7 @@ import useAdjustUrl from '@/hooks/useAdjustUrl' import useSafeMessageNotifications from '@/hooks/messages/useSafeMessageNotifications' import useSafeMessagePendingStatuses from '@/hooks/messages/useSafeMessagePendingStatuses' import useChangedValue from '@/hooks/useChangedValue' +import { TxModalProvider } from '@/components/tx-flow' const GATEWAY_URL = IS_PRODUCTION || cgwDebugStorage.get() ? GATEWAY_URL_PRODUCTION : GATEWAY_URL_STAGING @@ -72,7 +73,7 @@ export const AppProviders = ({ children }: { children: ReactNode | ReactNode[] } {(safeTheme: Theme) => ( <ThemeProvider theme={safeTheme}> <Sentry.ErrorBoundary showDialog fallback={ErrorBoundary}> - {children} + <TxModalProvider>{children}</TxModalProvider> </Sentry.ErrorBoundary> </ThemeProvider> )} diff --git a/src/services/exceptions/index.test.ts b/src/services/exceptions/__tests__/index.test.ts similarity index 81% rename from src/services/exceptions/index.test.ts rename to src/services/exceptions/__tests__/index.test.ts index d5e92f4b7a..205b43ce19 100644 --- a/src/services/exceptions/index.test.ts +++ b/src/services/exceptions/__tests__/index.test.ts @@ -1,4 +1,4 @@ -import { Errors, logError, trackError, CodedException } from '.' +import { Errors, logError, trackError, CodedException } from '..' import * as constants from '@/config/constants' import * as Sentry from '@sentry/react' @@ -37,13 +37,27 @@ describe('CodedException', () => { expect(err.content).toBe(Errors._100) }) - it('creates an error with an extra message', () => { + it('creates an error with an extra message from a string', () => { const err = new CodedException(Errors._100, '0x123') expect(err.message).toBe('Code 100: Invalid input in the address field (0x123)') expect(err.code).toBe(100) expect(err.content).toBe(Errors._100) }) + it('creates an error with an extra message from an Error instance', () => { + const err = new CodedException(Errors._100, new Error('0x123')) + expect(err.message).toBe('Code 100: Invalid input in the address field (0x123)') + expect(err.code).toBe(100) + expect(err.content).toBe(Errors._100) + }) + + it('creates an error with an extra message from an object', () => { + const err = new CodedException(Errors._100, { address: '0x123' }) + expect(err.message).toBe('Code 100: Invalid input in the address field ({"address":"0x123"})') + expect(err.code).toBe(100) + expect(err.content).toBe(Errors._100) + }) + it('creates an error with an extra message and a context', () => { const context = { tags: { diff --git a/src/services/exceptions/__tests__/utils.test.ts b/src/services/exceptions/__tests__/utils.test.ts new file mode 100644 index 0000000000..40984da620 --- /dev/null +++ b/src/services/exceptions/__tests__/utils.test.ts @@ -0,0 +1,34 @@ +import { asError } from '@/services/exceptions/utils' + +describe('utils', () => { + describe('asError', () => { + it('should return the same error if thrown is an instance of Error', () => { + const thrown = new Error('test error') + + expect(asError(thrown)).toEqual(new Error('test error')) + }) + + it('should return a new Error instance with the thrown value if thrown is a string', () => { + const thrown = 'test error' + + const result = asError(thrown) + expect(result).toEqual(new Error('test error')) + + // If stringified: + expect(result).not.toEqual(new Error('"test error')) + }) + it('should return a new Error instance with the stringified thrown value if thrown is not an instance of Error', () => { + const thrown = { message: 'test error' } + + expect(asError(thrown)).toEqual(new Error('{"message":"test error"}')) + }) + + it('should return a new Error instance with the string representation of thrown if JSON.stringify throws an error', () => { + // Circular dependency + const thrown: Record<string, unknown> = {} + thrown.a = { b: thrown } + + expect(asError(thrown)).toEqual(new Error('[object Object]')) + }) + }) +}) diff --git a/src/services/exceptions/index.ts b/src/services/exceptions/index.ts index af0e7701df..9db26380c0 100644 --- a/src/services/exceptions/index.ts +++ b/src/services/exceptions/index.ts @@ -2,6 +2,7 @@ import * as Sentry from '@sentry/react' import type { CaptureContext } from '@sentry/types' import { IS_PRODUCTION } from '@/config/constants' import ErrorCodes from './ErrorCodes' +import { asError } from './utils' export class CodedException extends Error { public readonly code: number @@ -20,10 +21,10 @@ export class CodedException extends Error { return code } - constructor(content: ErrorCodes, extraMessage?: string, context?: CaptureContext) { + constructor(content: ErrorCodes, thrown?: unknown, context?: CaptureContext) { super() - const extraInfo = extraMessage ? ` (${extraMessage})` : '' + const extraInfo = thrown ? ` (${asError(thrown).message})` : '' this.message = `Code ${content}${extraInfo}` this.code = this.getCode(content) this.content = content @@ -55,7 +56,7 @@ export class CodedException extends Error { } } -type ErrorHandler = (content: ErrorCodes, extraMessage?: string, context?: CaptureContext) => CodedException +type ErrorHandler = (content: ErrorCodes, thrown?: unknown, context?: CaptureContext) => CodedException export const logError: ErrorHandler = function logError(...args) { const error = new CodedException(...args) diff --git a/src/services/exceptions/utils.ts b/src/services/exceptions/utils.ts new file mode 100644 index 0000000000..710ac44641 --- /dev/null +++ b/src/services/exceptions/utils.ts @@ -0,0 +1,19 @@ +export const asError = (thrown: unknown): Error => { + if (thrown instanceof Error) { + return thrown + } + + let message: string + + if (typeof thrown === 'string') { + message = thrown + } else { + try { + message = JSON.stringify(thrown) + } catch { + message = String(thrown) + } + } + + return new Error(message) +} diff --git a/src/services/local-storage/Storage.ts b/src/services/local-storage/Storage.ts index 3bbce8b64d..40a58d4a3d 100644 --- a/src/services/local-storage/Storage.ts +++ b/src/services/local-storage/Storage.ts @@ -1,5 +1,6 @@ import { LS_NAMESPACE } from '@/config/constants' import { Errors, logError } from '@/services/exceptions' +import { asError } from '../exceptions/utils' type BrowserStorage = typeof localStorage | typeof sessionStorage @@ -27,7 +28,7 @@ class Storage { try { saved = this.storage?.getItem(fullKey) ?? null } catch (err) { - logError(Errors._700, `key ${key} – ${(err as Error).message}`) + logError(Errors._700, `key ${key} – ${asError(err).message}`) } if (saved == null) return null @@ -35,7 +36,7 @@ class Storage { try { return JSON.parse(saved) as T } catch (err) { - logError(Errors._700, `key ${key} – ${(err as Error).message}`) + logError(Errors._700, `key ${key} – ${asError(err).message}`) } return null } @@ -49,7 +50,7 @@ class Storage { this.storage?.setItem(fullKey, JSON.stringify(item)) } } catch (err) { - logError(Errors._701, `key ${key} – ${(err as Error).message}`) + logError(Errors._701, `key ${key} – ${asError(err).message}`) } } @@ -58,7 +59,7 @@ class Storage { try { this.storage?.removeItem(fullKey) } catch (err) { - logError(Errors._702, `key ${key} – ${(err as Error).message}`) + logError(Errors._702, `key ${key} – ${asError(err).message}`) } } diff --git a/src/services/pairing/hooks.ts b/src/services/pairing/hooks.ts index 377b0858b5..07292eb0f8 100644 --- a/src/services/pairing/hooks.ts +++ b/src/services/pairing/hooks.ts @@ -59,7 +59,7 @@ export const useInitPairing = () => { .then(() => { isConnecting = false }) - .catch((e) => logError(Errors._303, (e as Error).message)) + .catch((e) => logError(Errors._303, e)) }, [canConnect, chain, isSupported, onboard, connector]) useEffect(() => { diff --git a/src/services/safe-apps/AppCommunicator.ts b/src/services/safe-apps/AppCommunicator.ts index 54b2cedb00..0d2e5abdfa 100644 --- a/src/services/safe-apps/AppCommunicator.ts +++ b/src/services/safe-apps/AppCommunicator.ts @@ -1,6 +1,7 @@ import type { MutableRefObject } from 'react' import type { SDKMessageEvent, MethodToResponse, ErrorResponse, RequestId } from '@safe-global/safe-apps-sdk' import { getSDKVersion, Methods, MessageFormatter } from '@safe-global/safe-apps-sdk' +import { asError } from '../exceptions/utils' type MessageHandler = ( msg: SDKMessageEvent, @@ -69,7 +70,7 @@ class AppCommunicator { this.send(response, msg.data.id) } } catch (e) { - const error = e as Error + const error = asError(e) this.send(error.message, msg.data.id, true) this.config?.onError?.(error, msg.data) diff --git a/src/services/safe-messages/safeMsgNotifications.ts b/src/services/safe-messages/safeMsgNotifications.ts index d5456c6bd5..a3956824ba 100644 --- a/src/services/safe-messages/safeMsgNotifications.ts +++ b/src/services/safe-messages/safeMsgNotifications.ts @@ -27,7 +27,7 @@ export const dispatchPreparedSignature = async ( // fetchedMessage does not have a type because it is explicitly a message message = { ...fetchedMessage, type: SafeMessageListItemType.MESSAGE } } catch (err) { - logError(Errors._613, (err as Error).message) + logError(Errors._613, err) throw err } diff --git a/src/services/safe-messages/safeMsgSender.ts b/src/services/safe-messages/safeMsgSender.ts index 8382aa574b..47f0983268 100644 --- a/src/services/safe-messages/safeMsgSender.ts +++ b/src/services/safe-messages/safeMsgSender.ts @@ -7,6 +7,7 @@ import { safeMsgDispatch, SafeMsgEvent } from './safeMsgEvents' import { generateSafeMessageHash, tryOffChainMsgSigning } from '@/utils/safe-messages' import { normalizeTypedData } from '@/utils/web3' import { getAssertedChainSigner } from '@/services/tx/tx-sender/sdk' +import { asError } from '../exceptions/utils' export const dispatchSafeMsgProposal = async ({ onboard, @@ -38,7 +39,7 @@ export const dispatchSafeMsgProposal = async ({ } catch (error) { safeMsgDispatch(SafeMsgEvent.PROPOSE_FAILED, { messageHash, - error: error as Error, + error: asError(error), }) throw error @@ -70,7 +71,7 @@ export const dispatchSafeMsgConfirmation = async ({ } catch (error) { safeMsgDispatch(SafeMsgEvent.CONFIRM_PROPOSE_FAILED, { messageHash, - error: error as Error, + error: asError(error), }) throw error diff --git a/src/services/security/modules/ApprovalModule/index.ts b/src/services/security/modules/ApprovalModule/index.ts index b2b8087da6..3fc3238f6d 100644 --- a/src/services/security/modules/ApprovalModule/index.ts +++ b/src/services/security/modules/ApprovalModule/index.ts @@ -21,7 +21,7 @@ const MULTISEND_SIGNATURE_HASH = id('multiSend(bytes)').slice(0, 10) const ERC20_INTERFACE = ERC20__factory.createInterface() export class ApprovalModule implements SecurityModule<ApprovalModuleRequest, ApprovalModuleResponse> { - private scanInnerTransaction(txPartial: { to: string; data: string }): Approval[] { + private static scanInnerTransaction(txPartial: { to: string; data: string }): Approval[] { if (txPartial.data.startsWith(APPROVAL_SIGNATURE_HASH)) { const [spender, amount] = ERC20_INTERFACE.decodeFunctionData('approve', txPartial.data) return [ @@ -35,16 +35,16 @@ export class ApprovalModule implements SecurityModule<ApprovalModuleRequest, App return [] } - async scanTransaction(request: ApprovalModuleRequest): Promise<SecurityResponse<ApprovalModuleResponse>> { + scanTransaction(request: ApprovalModuleRequest): SecurityResponse<ApprovalModuleResponse> { const { safeTransaction } = request const safeTxData = safeTransaction.data.data const approvalInfos: Approval[] = [] if (safeTxData.startsWith(MULTISEND_SIGNATURE_HASH)) { const innerTxs = decodeMultiSendTxs(safeTxData) - approvalInfos.push(...innerTxs.flatMap((tx) => this.scanInnerTransaction(tx))) + approvalInfos.push(...innerTxs.flatMap((tx) => ApprovalModule.scanInnerTransaction(tx))) } else { - approvalInfos.push(...this.scanInnerTransaction({ to: safeTransaction.data.to, data: safeTxData })) + approvalInfos.push(...ApprovalModule.scanInnerTransaction({ to: safeTransaction.data.to, data: safeTxData })) } if (approvalInfos.length > 0) { diff --git a/src/services/security/modules/types.ts b/src/services/security/modules/types.ts index 4c46aafac3..415fe2cd0a 100644 --- a/src/services/security/modules/types.ts +++ b/src/services/security/modules/types.ts @@ -17,5 +17,5 @@ export type SecurityResponse<Res> = } export interface SecurityModule<Req, Res> { - scanTransaction(request: Req): Promise<SecurityResponse<Res>> + scanTransaction(request: Req): Promise<SecurityResponse<Res>> | SecurityResponse<Res> } diff --git a/src/services/tx/__tests__/spendingLimitParams.test.ts b/src/services/tx/__tests__/spendingLimitParams.test.ts index eb8b6e4b4f..28ed94fe03 100644 --- a/src/services/tx/__tests__/spendingLimitParams.test.ts +++ b/src/services/tx/__tests__/spendingLimitParams.test.ts @@ -1,4 +1,4 @@ -import type { NewSpendingLimitData } from '@/components/settings/SpendingLimits/NewSpendingLimit' +import type { NewSpendingLimitFlowProps } from '@/components/tx-flow/flows/NewSpendingLimit' import { ZERO_ADDRESS } from '@safe-global/safe-core-sdk/dist/src/utils/constants' import * as safeCoreSDK from '@/hooks/coreSDK/safeCoreSDK' import * as spendingLimit from '@/services/contracts/spendingLimitContracts' @@ -8,7 +8,7 @@ import type Safe from '@safe-global/safe-core-sdk' import type { SpendingLimitState } from '@/store/spendingLimitsSlice' import { createNewSpendingLimitTx } from '@/services/tx/tx-sender' -const mockData: NewSpendingLimitData = { +const mockData: NewSpendingLimitFlowProps = { beneficiary: ZERO_ADDRESS, tokenAddress: ZERO_ADDRESS, amount: '1', diff --git a/src/services/tx/tx-sender/index.test.ts b/src/services/tx/tx-sender/__tests__/ts-sender.test.ts similarity index 93% rename from src/services/tx/tx-sender/index.test.ts rename to src/services/tx/tx-sender/__tests__/ts-sender.test.ts index a82de18537..23965f53a6 100644 --- a/src/services/tx/tx-sender/index.test.ts +++ b/src/services/tx/tx-sender/__tests__/ts-sender.test.ts @@ -2,10 +2,10 @@ import { setSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' import type Safe from '@safe-global/safe-core-sdk' import { type TransactionResult } from '@safe-global/safe-core-sdk-types' import { type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' -import { getTransactionDetails, postSafeGasEstimation } from '@safe-global/safe-gateway-typescript-sdk' -import extractTxInfo from '../extractTxInfo' -import proposeTx from '../proposeTransaction' -import * as txEvents from '../txEvents' +import { getTransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import extractTxInfo from '../../extractTxInfo' +import proposeTx from '../../proposeTransaction' +import * as txEvents from '../../txEvents' import { createExistingTx, createRejectTx, @@ -14,7 +14,7 @@ import { dispatchTxProposal, dispatchTxSigning, dispatchBatchExecutionRelay, -} from '.' +} from '..' import { ErrorCode } from '@ethersproject/logger' import { waitFor } from '@/tests/test-utils' import { ethers } from 'ethers' @@ -41,7 +41,7 @@ jest.mock('@safe-global/safe-gateway-typescript-sdk', () => ({ })) // Mock extractTxInfo -jest.mock('../extractTxInfo', () => ({ +jest.mock('../../extractTxInfo', () => ({ __esModule: true, default: jest.fn(() => ({ txParams: {}, @@ -50,7 +50,7 @@ jest.mock('../extractTxInfo', () => ({ })) // Mock proposeTx -jest.mock('../proposeTransaction', () => ({ +jest.mock('../../proposeTransaction', () => ({ __esModule: true, default: jest.fn(() => Promise.resolve({ txId: '123' })), })) @@ -141,6 +141,7 @@ describe('txSender', () => { to: '0x123', value: '1', data: '0x0', + safeTxGas: 60000, } await createTx(txParams) @@ -148,7 +149,6 @@ describe('txSender', () => { to: '0x123', value: '1', data: '0x0', - nonce: 17, safeTxGas: 60000, } expect(mockSafeSDK.createTransaction).toHaveBeenCalledWith({ safeTransactionData }) @@ -171,26 +171,6 @@ describe('txSender', () => { } expect(mockSafeSDK.createTransaction).toHaveBeenCalledWith({ safeTransactionData }) }) - - it('should create a tx with initial txParams if gas estimation fails', async () => { - // override postSafeGasEstimation default implementation - ;(postSafeGasEstimation as jest.Mock) - .mockImplementationOnce(() => Promise.reject(new Error('Failed to retrieve recommended nonce #1'))) - .mockImplementationOnce(() => Promise.reject(new Error('Failed to retrieve recommended nonce #2'))) - - const txParams = { - to: '0x123', - value: '1', - data: '0x0', - } - await createTx(txParams) - - // Shallow copy of txParams - const safeTransactionData = Object.assign({}, txParams) - - // calls SDK createTransaction withouth recommended nonce - expect(mockSafeSDK.createTransaction).toHaveBeenCalledWith({ safeTransactionData }) - }) }) describe('createExistingTx', () => { @@ -534,7 +514,7 @@ describe('txSender', () => { await waitFor(() => expect(txEvents.txDispatch).toHaveBeenCalledWith('FAILED', { txId: 'tx_id_123', - error: { code: ErrorCode.TRANSACTION_REPLACED, reason: 'cancelled' }, + error: new Error(JSON.stringify({ code: ErrorCode.TRANSACTION_REPLACED, reason: 'cancelled' })), }), ) }) diff --git a/src/services/tx/tx-sender/create.ts b/src/services/tx/tx-sender/create.ts index 79031b41ed..bf4073c209 100644 --- a/src/services/tx/tx-sender/create.ts +++ b/src/services/tx/tx-sender/create.ts @@ -1,121 +1,56 @@ -import { isLegacyVersion } from '@/hooks/coreSDK/safeCoreSDK' -import { Errors, logError } from '@/services/exceptions' -import type { SafeTransactionEstimation, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' -import { getTransactionDetails, Operation, postSafeGasEstimation } from '@safe-global/safe-gateway-typescript-sdk' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { getTransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import type { AddOwnerTxParams, RemoveOwnerTxParams, SwapOwnerTxParams } from '@safe-global/safe-core-sdk' import type { MetaTransactionData, SafeTransaction, SafeTransactionDataPartial } from '@safe-global/safe-core-sdk-types' -import { EMPTY_DATA } from '@safe-global/safe-core-sdk/dist/src/utils/constants' import extractTxInfo from '../extractTxInfo' import { getAndValidateSafeSDK } from './sdk' -import type Safe from '@safe-global/safe-core-sdk' - -const estimateSafeTxGas = async ( - chainId: string, - safeAddress: string, - txParams: MetaTransactionData, -): Promise<SafeTransactionEstimation> => { - return postSafeGasEstimation(chainId, safeAddress, { - to: txParams.to, - value: txParams.value, - data: txParams.data, - operation: (txParams.operation as unknown as Operation) || Operation.CALL, - }) -} - -const getRecommendedTxParams = async ( - txParams: SafeTransactionDataPartial, -): Promise<{ nonce: number; safeTxGas: number } | undefined> => { - const safeSDK = getAndValidateSafeSDK() - const chainId = await safeSDK.getChainId() - const safeAddress = safeSDK.getAddress() - const contractVersion = await safeSDK.getContractVersion() - const isSafeTxGasRequired = isLegacyVersion(contractVersion) - - let estimation: SafeTransactionEstimation | undefined - - try { - estimation = await estimateSafeTxGas(String(chainId), safeAddress, txParams) - } catch (e) { - try { - // If the initial transaction data causes the estimation to fail, - // we retry the request with a cancellation transaction to get the - // recommendedNonce, even if the original transaction will likely fail - const cancellationTxParams = { ...txParams, data: EMPTY_DATA, to: safeAddress, value: '0' } - estimation = await estimateSafeTxGas(String(chainId), safeAddress, cancellationTxParams) - } catch (e) { - logError(Errors._616, (e as Error).message) - return - } - } - - return { - nonce: estimation.recommendedNonce, - safeTxGas: isSafeTxGasRequired ? Number(estimation.safeTxGas) : 0, - } -} /** * Create a transaction from raw params */ export const createTx = async (txParams: SafeTransactionDataPartial, nonce?: number): Promise<SafeTransaction> => { + if (nonce !== undefined) txParams = { ...txParams, nonce } const safeSDK = getAndValidateSafeSDK() - - // If the nonce is not provided, we get the recommended one - if (nonce === undefined) { - const recParams = (await getRecommendedTxParams(txParams)) || {} - txParams = { ...txParams, ...recParams } - } else { - // Otherwise, we use the provided one - txParams = { ...txParams, nonce } - } - return safeSDK.createTransaction({ safeTransactionData: txParams }) } -const withRecommendedNonce = async ( - createFn: (safeSDK: Safe) => Promise<SafeTransaction>, -): Promise<SafeTransaction> => { - const safeSDK = getAndValidateSafeSDK() - const tx = await createFn(safeSDK) - return createTx(tx.data) -} - /** * Create a multiSendCallOnly transaction from an array of MetaTransactionData and options - * * If only one tx is passed it will be created without multiSend and without onlyCalls. */ export const createMultiSendCallOnlyTx = async (txParams: MetaTransactionData[]): Promise<SafeTransaction> => { - return withRecommendedNonce((safeSDK) => - safeSDK.createTransaction({ - safeTransactionData: txParams, - onlyCalls: true, - }), - ) + const safeSDK = getAndValidateSafeSDK() + return safeSDK.createTransaction({ safeTransactionData: txParams, onlyCalls: true }) } export const createRemoveOwnerTx = async (txParams: RemoveOwnerTxParams): Promise<SafeTransaction> => { - return withRecommendedNonce((safeSDK) => safeSDK.createRemoveOwnerTx(txParams)) + const safeSDK = getAndValidateSafeSDK() + return safeSDK.createRemoveOwnerTx(txParams) } export const createAddOwnerTx = async (txParams: AddOwnerTxParams): Promise<SafeTransaction> => { - return withRecommendedNonce((safeSDK) => safeSDK.createAddOwnerTx(txParams)) + const safeSDK = getAndValidateSafeSDK() + return safeSDK.createAddOwnerTx(txParams) } export const createSwapOwnerTx = async (txParams: SwapOwnerTxParams): Promise<SafeTransaction> => { - return withRecommendedNonce((safeSDK) => safeSDK.createSwapOwnerTx(txParams)) + const safeSDK = getAndValidateSafeSDK() + return safeSDK.createSwapOwnerTx(txParams) } export const createUpdateThresholdTx = async (threshold: number): Promise<SafeTransaction> => { - return withRecommendedNonce((safeSDK) => safeSDK.createChangeThresholdTx(threshold)) + const safeSDK = getAndValidateSafeSDK() + return safeSDK.createChangeThresholdTx(threshold) } export const createRemoveModuleTx = async (moduleAddress: string): Promise<SafeTransaction> => { - return withRecommendedNonce((safeSDK) => safeSDK.createDisableModuleTx(moduleAddress)) + const safeSDK = getAndValidateSafeSDK() + return safeSDK.createDisableModuleTx(moduleAddress) } export const createRemoveGuardTx = async (): Promise<SafeTransaction> => { - return withRecommendedNonce((safeSDK) => safeSDK.createDisableGuardTx()) + const safeSDK = getAndValidateSafeSDK() + return safeSDK.createDisableGuardTx() } /** diff --git a/src/services/tx/tx-sender/dispatch.ts b/src/services/tx/tx-sender/dispatch.ts index 0a05153bda..85cdb18014 100644 --- a/src/services/tx/tx-sender/dispatch.ts +++ b/src/services/tx/tx-sender/dispatch.ts @@ -3,7 +3,7 @@ import type { SafeTransaction, TransactionOptions, TransactionResult } from '@sa import type { EthersError } from '@/utils/ethers-utils' import { didReprice, didRevert } from '@/utils/ethers-utils' import type MultiSendCallOnlyEthersContract from '@safe-global/safe-ethers-lib/dist/src/contracts/MultiSendCallOnly/MultiSendCallOnlyEthersContract' -import type { SpendingLimitTxParams } from '@/components/tx/modals/TokenTransferModal/ReviewSpendingLimitTx' +import { type SpendingLimitTxParams } from '@/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx' import { getSpendingLimitContract } from '@/services/contracts/spendingLimitContracts' import type { ContractTransaction, PayableOverrides } from 'ethers' import type { RequestId } from '@safe-global/safe-apps-sdk' @@ -21,6 +21,7 @@ import { } from './sdk' import { createWeb3 } from '@/hooks/wallets/web3' import { type OnboardAPI } from '@web3-onboard/core' +import { asError } from '@/services/exceptions/utils' /** * Propose a transaction @@ -49,9 +50,9 @@ export const dispatchTxProposal = async ({ proposedTx = await proposeTx(chainId, safeAddress, sender, safeTx, safeTxHash, origin) } catch (error) { if (txId) { - txDispatch(TxEvent.SIGNATURE_PROPOSE_FAILED, { txId, error: error as Error }) + txDispatch(TxEvent.SIGNATURE_PROPOSE_FAILED, { txId, error: asError(error) }) } else { - txDispatch(TxEvent.PROPOSE_FAILED, { error: error as Error }) + txDispatch(TxEvent.PROPOSE_FAILED, { error: asError(error) }) } throw error } @@ -80,7 +81,7 @@ export const dispatchTxSigning = async ( try { signedTx = await tryOffChainTxSigning(safeTx, safeVersion, sdk) } catch (error) { - txDispatch(TxEvent.SIGN_FAILED, { txId, error: error as Error }) + txDispatch(TxEvent.SIGN_FAILED, { txId, error: asError(error) }) throw error } @@ -108,7 +109,7 @@ export const dispatchOnChainSigning = async ( await sdkUnchecked.approveTransactionHash(safeTxHash) txDispatch(TxEvent.ONCHAIN_SIGNATURE_REQUESTED, eventParams) } catch (err) { - txDispatch(TxEvent.FAILED, { ...eventParams, error: err as Error }) + txDispatch(TxEvent.FAILED, { ...eventParams, error: asError(err) }) throw err } @@ -138,7 +139,7 @@ export const dispatchTxExecution = async ( result = await sdkUnchecked.executeTransaction(safeTx, txOptions) txDispatch(TxEvent.EXECUTING, eventParams) } catch (error) { - txDispatch(TxEvent.FAILED, { ...eventParams, error: error as Error }) + txDispatch(TxEvent.FAILED, { ...eventParams, error: asError(error) }) throw error } @@ -160,7 +161,7 @@ export const dispatchTxExecution = async ( if (didReprice(error)) { txDispatch(TxEvent.PROCESSED, { ...eventParams, safeAddress }) } else { - txDispatch(TxEvent.FAILED, { ...eventParams, error: error as Error }) + txDispatch(TxEvent.FAILED, { ...eventParams, error: asError(error) }) } }) @@ -191,7 +192,7 @@ export const dispatchBatchExecution = async ( }) } catch (err) { txs.forEach(({ txId }) => { - txDispatch(TxEvent.FAILED, { txId, error: err as Error, groupKey }) + txDispatch(TxEvent.FAILED, { txId, error: asError(err), groupKey }) }) throw err } @@ -232,7 +233,7 @@ export const dispatchBatchExecution = async ( txs.forEach(({ txId }) => { txDispatch(TxEvent.FAILED, { txId, - error: err as Error, + error: asError(err), groupKey, }) }) @@ -270,7 +271,7 @@ export const dispatchSpendingLimitTxExecution = async ( ) txDispatch(TxEvent.EXECUTING, { groupKey: id }) } catch (error) { - txDispatch(TxEvent.FAILED, { groupKey: id, error: error as Error }) + txDispatch(TxEvent.FAILED, { groupKey: id, error: asError(error) }) throw error } @@ -289,7 +290,7 @@ export const dispatchSpendingLimitTxExecution = async ( } }) .catch((error) => { - txDispatch(TxEvent.FAILED, { groupKey: id, error: error as Error }) + txDispatch(TxEvent.FAILED, { groupKey: id, error: asError(error) }) }) return result?.hash @@ -341,7 +342,7 @@ export const dispatchTxRelay = async ( // Monitor relay tx waitForRelayedTx(taskId, [txId], safe.address.value) } catch (error) { - txDispatch(TxEvent.FAILED, { txId, error: error as Error }) + txDispatch(TxEvent.FAILED, { txId, error: asError(error) }) throw error } } @@ -368,7 +369,7 @@ export const dispatchBatchExecutionRelay = async ( txs.forEach(({ txId }) => { txDispatch(TxEvent.FAILED, { txId, - error: error as Error, + error: asError(error), groupKey, }) }) diff --git a/src/services/tx/tx-sender/recommendedNonce.ts b/src/services/tx/tx-sender/recommendedNonce.ts new file mode 100644 index 0000000000..a1697e2372 --- /dev/null +++ b/src/services/tx/tx-sender/recommendedNonce.ts @@ -0,0 +1,50 @@ +import type { SafeTransactionEstimation } from '@safe-global/safe-gateway-typescript-sdk' +import { Operation, postSafeGasEstimation } from '@safe-global/safe-gateway-typescript-sdk' +import type { MetaTransactionData, SafeTransactionDataPartial } from '@safe-global/safe-core-sdk-types' +import { isLegacyVersion } from '@/hooks/coreSDK/safeCoreSDK' +import { Errors, logError } from '@/services/exceptions' +import { getAndValidateSafeSDK } from './sdk' +import { EMPTY_DATA } from '@safe-global/safe-core-sdk/dist/src/utils/constants' + +const fetchRecommendedParams = async ( + chainId: string, + safeAddress: string, + txParams: MetaTransactionData, +): Promise<SafeTransactionEstimation> => { + return postSafeGasEstimation(chainId, safeAddress, { + to: txParams.to, + value: txParams.value, + data: txParams.data, + operation: (txParams.operation as unknown as Operation) || Operation.CALL, + }) +} + +export const getSafeTxGas = async ( + chainId: string, + safeAddress: string, + safeTxData: SafeTransactionDataPartial, +): Promise<number | undefined> => { + const safeSDK = getAndValidateSafeSDK() + const contractVersion = await safeSDK.getContractVersion() + const isSafeTxGasRequired = isLegacyVersion(contractVersion) + + // For 1.3.0+ Safes safeTxGas is not required + if (!isSafeTxGasRequired) return 0 + + try { + const estimation = await fetchRecommendedParams(chainId, safeAddress, safeTxData) + return Number(estimation.safeTxGas) + } catch (e) { + logError(Errors._616, e) + } +} + +export const getRecommendedNonce = async (chainId: string, safeAddress: string): Promise<number | undefined> => { + const blankTxParams = { data: EMPTY_DATA, to: safeAddress, value: '0' } + try { + const estimation = await fetchRecommendedParams(chainId, safeAddress, blankTxParams) + return Number(estimation.recommendedNonce) + } catch (e) { + logError(Errors._616, e) + } +} diff --git a/src/services/tx/tx-sender/sdk.ts b/src/services/tx/tx-sender/sdk.ts index 529711770e..331e28e8a6 100644 --- a/src/services/tx/tx-sender/sdk.ts +++ b/src/services/tx/tx-sender/sdk.ts @@ -13,6 +13,7 @@ import { connectWallet, getConnectedWallet } from '@/hooks/wallets/useOnboard' import { type OnboardAPI } from '@web3-onboard/core' import type { ConnectedWallet } from '@/services/onboard' import type { JsonRpcSigner } from '@ethersproject/providers' +import { asError } from '@/services/exceptions/utils' export const getAndValidateSafeSDK = (): Safe => { const safeSDK = getSafeSDK() @@ -146,7 +147,7 @@ export const tryOffChainTxSigning = async ( } catch (error) { const isLastSigningMethod = i === signingMethods.length - 1 - if (isWalletRejection(error as Error) || isLastSigningMethod) { + if (isWalletRejection(asError(error)) || isLastSigningMethod) { throw error } } diff --git a/src/services/tx/txMonitor.ts b/src/services/tx/txMonitor.ts index 190abf80ee..b75ac03f8d 100644 --- a/src/services/tx/txMonitor.ts +++ b/src/services/tx/txMonitor.ts @@ -6,6 +6,7 @@ import type { JsonRpcProvider } from '@ethersproject/providers' import { POLLING_INTERVAL } from '@/config/constants' import { Errors, logError } from '@/services/exceptions' import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' +import { asError } from '../exceptions/utils' // Provider must be passed as an argument as it is undefined until initialised by `useInitWeb3` export const waitForTx = async (provider: JsonRpcProvider, txId: string, txHash: string) => { @@ -33,7 +34,7 @@ export const waitForTx = async (provider: JsonRpcProvider, txId: string, txHash: } catch (error) { txDispatch(TxEvent.FAILED, { txId, - error: error as Error, + error: asError(error), }) } } @@ -81,7 +82,7 @@ const getRelayTxStatus = async (taskId: string): Promise<{ task: TransactionStat }) }) } catch (error) { - logError(Errors._632, (error as Error).message) + logError(Errors._632, error) return } diff --git a/src/styles/accordion.module.css b/src/styles/accordion.module.css new file mode 100644 index 0000000000..296751c2a5 --- /dev/null +++ b/src/styles/accordion.module.css @@ -0,0 +1,5 @@ +/* TODO: Apply this style in the MUI theme once its part of this repository */ + +.accordion { + min-height: 56px !important; +} diff --git a/src/styles/globals.css b/src/styles/globals.css index f828e0bf22..1734b5b58e 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -74,7 +74,7 @@ input[type='number'] { stroke: var(--color-logo-background); } -@media (max-width: 600px) { +@media (max-width: 599.95px) { .sticky { position: sticky; right: 0; diff --git a/src/styles/inputs.module.css b/src/styles/inputs.module.css new file mode 100644 index 0000000000..0793279324 --- /dev/null +++ b/src/styles/inputs.module.css @@ -0,0 +1,35 @@ +/* TODO: Apply these styles in the MUI theme once its part of this repository */ +.input :global .MuiFormHelperText-root { + position: absolute; + bottom: -20px; +} + +.input :global .MuiFormLabel-root:not(.MuiInputLabel-shrink) { + transform: translate(16px, 22px) scale(1); +} + +.input :global .MuiInputBase-root { + background-color: var(--color-background-paper); + border-radius: 6px; + height: 66px; + padding: 12px var(--space-2); +} + +.input input { + padding: 0; +} + +.input :global .MuiInputBase-root fieldset { + border-width: 1px !important; +} + +.input :global .MuiInputBase-root:not(.Mui-error) fieldset { + border-color: var(--color-border-light) !important; +} + +@media (max-width: 899.95px) { + .input :global .MuiFormHelperText-root { + position: relative; + bottom: 0; + } +} diff --git a/src/tests/mocks/transactions.ts b/src/tests/mocks/transactions.ts new file mode 100644 index 0000000000..cc61f95847 --- /dev/null +++ b/src/tests/mocks/transactions.ts @@ -0,0 +1,60 @@ +import { + type AddressEx, + ConflictType, + DetailedExecutionInfoType, + type MultisigExecutionInfo, + type Transaction, + type TransactionInfo, + TransactionInfoType, + TransactionListItemType, + TransactionStatus, + type TransactionSummary, + TransactionTokenType, + TransferDirection, + type TransferInfo, +} from '@safe-global/safe-gateway-typescript-sdk' + +const mockAddressEx: AddressEx = { + value: 'string', +} + +const mockTransferInfo: TransferInfo = { + type: TransactionTokenType.ERC20, + tokenAddress: 'string', + value: 'string', +} + +const mockTxInfo: TransactionInfo = { + type: TransactionInfoType.TRANSFER, + sender: mockAddressEx, + recipient: mockAddressEx, + direction: TransferDirection.OUTGOING, + transferInfo: mockTransferInfo, +} + +export const defaultTx: TransactionSummary = { + id: '', + timestamp: 0, + txInfo: mockTxInfo, + txStatus: TransactionStatus.AWAITING_CONFIRMATIONS, + executionInfo: { + type: DetailedExecutionInfoType.MULTISIG, + nonce: 1, + confirmationsRequired: 2, + confirmationsSubmitted: 2, + }, +} + +export const getMockTx = ({ nonce }: { nonce?: number }): Transaction => { + return { + transaction: { + ...defaultTx, + executionInfo: { + ...defaultTx.executionInfo, + nonce: nonce ?? (defaultTx.executionInfo as MultisigExecutionInfo).nonce, + } as MultisigExecutionInfo, + }, + type: TransactionListItemType.TRANSACTION, + conflictType: ConflictType.NONE, + } +} diff --git a/src/tests/pages/apps-share.test.tsx b/src/tests/pages/apps-share.test.tsx index 915261b68c..598c6bb95b 100644 --- a/src/tests/pages/apps-share.test.tsx +++ b/src/tests/pages/apps-share.test.tsx @@ -108,7 +108,7 @@ describe('Share Safe App Page', () => { render(<ShareSafeApp />, { routerProps: { query: { - appUrl: 'https://apps.gnosis-safe.io/tx-builder/', + appUrl: 'https://apps-portal.safe.global/tx-builder/', chain: 'rin', }, }, diff --git a/src/utils/ethers-utils.ts b/src/utils/ethers-utils.ts index 90c8063a3d..5b611b706a 100644 --- a/src/utils/ethers-utils.ts +++ b/src/utils/ethers-utils.ts @@ -8,7 +8,7 @@ export enum EthersTxReplacedReason { replaced = 'replaced', } -// TODO: Replace this with ethers v6 types once released +// TODO: Replace this with ethers v6 types once released and create similar helper to `asError` export type EthersError = Error & { code: ErrorCode; reason?: EthersTxReplacedReason; receipt?: TransactionReceipt } export const didRevert = (receipt: EthersError['receipt']): boolean => { diff --git a/src/utils/safe-messages.ts b/src/utils/safe-messages.ts index c83956bd50..123764b175 100644 --- a/src/utils/safe-messages.ts +++ b/src/utils/safe-messages.ts @@ -17,6 +17,7 @@ import { } from '@safe-global/safe-gateway-typescript-sdk' import { hasFeature } from './chains' +import { asError } from '@/services/exceptions/utils' /* * From v1.3.0, EIP-1271 support was moved to the CompatibilityFallbackHandler. @@ -131,7 +132,7 @@ export const tryOffChainMsgSigning = async ( } catch (error) { const isLastSigningMethod = i === signingMethods.length - 1 - if (isWalletRejection(error as Error) || isLastSigningMethod) { + if (isWalletRejection(asError(error)) || isLastSigningMethod) { throw error } } diff --git a/src/utils/transactions.ts b/src/utils/transactions.ts index f5dd81f4a2..c6a8dbd856 100644 --- a/src/utils/transactions.ts +++ b/src/utils/transactions.ts @@ -22,7 +22,7 @@ import { OperationType } from '@safe-global/safe-core-sdk-types/dist/src/types' import { getReadOnlyGnosisSafeContract } from '@/services/contracts/safeContracts' import extractTxInfo from '@/services/tx/extractTxInfo' import type { AdvancedParameters } from '@/components/tx/AdvancedParams' -import type { TransactionOptions, SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction, TransactionOptions } from '@safe-global/safe-core-sdk-types' import { FEATURES, hasFeature } from '@/utils/chains' import uniqBy from 'lodash/uniqBy' import { Errors, logError } from '@/services/exceptions' @@ -30,6 +30,7 @@ import { Multi_send__factory } from '@/types/contracts' import { ethers } from 'ethers' import { type BaseTransaction } from '@safe-global/safe-apps-sdk' import { id } from 'ethers/lib/utils' +import { isEmptyHexData } from '@/utils/hex' export const makeTxFromDetails = (txDetails: TransactionDetails): Transaction => { const getMissingSigners = ({ @@ -188,7 +189,7 @@ export const getTxOrigin = (app?: SafeAppData): string | undefined => { try { origin = JSON.stringify({ name: app.name, url: app.url }) } catch (e) { - logError(Errors._808, (e as Error).message) + logError(Errors._808, e) } return origin @@ -266,3 +267,7 @@ export const decodeMultiSendTxs = (encodedMultiSendData: string): BaseTransactio return txs } + +export const isRejectionTx = (tx?: SafeTransaction) => { + return !!tx && !!tx.data.data && !!isEmptyHexData(tx.data.data) && tx.data.value === '0' +} diff --git a/yarn.lock b/yarn.lock index 7c8ab02687..95a1f994e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1003,10 +1003,10 @@ dependencies: regenerator-runtime "^0.13.11" -"@babel/runtime@^7.20.13": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673" - integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw== +"@babel/runtime@^7.21.0": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.5.tgz#8564dd588182ce0047d55d7a75e93921107b57ec" + integrity sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA== dependencies: regenerator-runtime "^0.13.11" @@ -1178,6 +1178,17 @@ "@emotion/weak-memoize" "^0.3.0" stylis "4.1.3" +"@emotion/cache@^11.11.0": + version "11.11.0" + resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff" + integrity sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ== + dependencies: + "@emotion/memoize" "^0.8.1" + "@emotion/sheet" "^1.2.2" + "@emotion/utils" "^1.2.1" + "@emotion/weak-memoize" "^0.3.1" + stylis "4.2.0" + "@emotion/hash@^0.9.0": version "0.9.0" resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.0.tgz#c5153d50401ee3c027a57a177bc269b16d889cb7" @@ -1190,11 +1201,23 @@ dependencies: "@emotion/memoize" "^0.8.0" +"@emotion/is-prop-valid@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz#23116cf1ed18bfeac910ec6436561ecb1a3885cc" + integrity sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw== + dependencies: + "@emotion/memoize" "^0.8.1" + "@emotion/memoize@^0.8.0": version "0.8.0" resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.0.tgz#f580f9beb67176fa57aae70b08ed510e1b18980f" integrity sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA== +"@emotion/memoize@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.1.tgz#c1ddb040429c6d21d38cc945fe75c818cfb68e17" + integrity sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA== + "@emotion/react@^11.10.0": version "11.10.5" resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.10.5.tgz#95fff612a5de1efa9c0d535384d3cfa115fe175d" @@ -1235,6 +1258,11 @@ resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.2.1.tgz#0767e0305230e894897cadb6c8df2c51e61a6c2c" integrity sha512-zxRBwl93sHMsOj4zs+OslQKg/uhF38MB+OMKoCrVuS0nyTkqnau+BM3WGEoOptg9Oz45T/aIGs1qbVAsEFo3nA== +"@emotion/sheet@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.2.2.tgz#d58e788ee27267a14342303e1abb3d508b6d0fec" + integrity sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA== + "@emotion/styled@^11.10.0": version "11.10.5" resolved "https://registry.yarnpkg.com/@emotion/styled/-/styled-11.10.5.tgz#1fe7bf941b0909802cb826457e362444e7e96a79" @@ -1262,11 +1290,21 @@ resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.0.tgz#9716eaccbc6b5ded2ea5a90d65562609aab0f561" integrity sha512-sn3WH53Kzpw8oQ5mgMmIzzyAaH2ZqFEbozVVBSYp538E06OSE6ytOp7pRAjNQR+Q/orwqdQYJSe2m3hCOeznkw== +"@emotion/utils@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.1.tgz#bbab58465738d31ae4cb3dbb6fc00a5991f755e4" + integrity sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg== + "@emotion/weak-memoize@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz#ea89004119dc42db2e1dba0f97d553f7372f6fcb" integrity sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg== +"@emotion/weak-memoize@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz#d0fce5d07b0620caa282b5131c297bb60f9d87e6" + integrity sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww== + "@eslint/eslintrc@^1.4.1": version "1.4.1" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.4.1.tgz#af58772019a2d271b7e2d4c23ff4ddcba3ccfb3e" @@ -2727,24 +2765,24 @@ "@motionone/dom" "^10.16.2" tslib "^2.3.1" -"@mui/base@5.0.0-alpha.118": - version "5.0.0-alpha.118" - resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-alpha.118.tgz#335e7496ea605c9b7bda4164efb2da3f09f36dfc" - integrity sha512-GAEpqhnuHjRaAZLdxFNuOf2GDTp9sUawM46oHZV4VnYPFjXJDkIYFWfIQLONb0nga92OiqS5DD/scGzVKCL0Mw== +"@mui/base@5.0.0-beta.4": + version "5.0.0-beta.4" + resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-beta.4.tgz#e3f4f4a056b88ab357194a245e223177ce35e0b0" + integrity sha512-ejhtqYJpjDgHGEljjMBQWZ22yEK0OzIXNa7toJmmXsP4TT3W7xVy8bTJ0TniPDf+JNjrsgfgiFTDGdlEhV1E+g== dependencies: - "@babel/runtime" "^7.20.13" - "@emotion/is-prop-valid" "^1.2.0" - "@mui/types" "^7.2.3" - "@mui/utils" "^5.11.9" - "@popperjs/core" "^2.11.6" + "@babel/runtime" "^7.21.0" + "@emotion/is-prop-valid" "^1.2.1" + "@mui/types" "^7.2.4" + "@mui/utils" "^5.13.1" + "@popperjs/core" "^2.11.8" clsx "^1.2.1" prop-types "^15.8.1" react-is "^18.2.0" -"@mui/core-downloads-tracker@^5.11.9": - version "5.11.9" - resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.11.9.tgz#0d3b20c2ef7704537c38597f9ecfc1894fe7c367" - integrity sha512-YGEtucQ/Nl91VZkzYaLad47Cdui51n/hW+OQm4210g4N3/nZzBxmGeKfubEalf+ShKH4aYDS86XTO6q/TpZnjQ== +"@mui/core-downloads-tracker@^5.13.4": + version "5.13.4" + resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.4.tgz#7e4b491d8081b6d45ae51556d82cb16b31315a19" + integrity sha512-yFrMWcrlI0TqRN5jpb6Ma9iI7sGTHpytdzzL33oskFHNQ8UgrtPas33Y1K7sWAMwCrr1qbWDrOHLAQG4tAzuSw== "@mui/icons-material@^5.8.4": version "5.11.0" @@ -2753,61 +2791,61 @@ dependencies: "@babel/runtime" "^7.20.6" -"@mui/material@^5.11.10": - version "5.11.10" - resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.11.10.tgz#d1a7e1691b36eb6aab0f41a82e9c5c564699f599" - integrity sha512-hs1WErbiedqlJIZsljgoil908x4NMp8Lfk8di+5c7o809roqKcFTg2+k3z5ucKvs29AXcsdXrDB/kn2K6dGYIw== - dependencies: - "@babel/runtime" "^7.20.13" - "@mui/base" "5.0.0-alpha.118" - "@mui/core-downloads-tracker" "^5.11.9" - "@mui/system" "^5.11.9" - "@mui/types" "^7.2.3" - "@mui/utils" "^5.11.9" - "@types/react-transition-group" "^4.4.5" +"@mui/material@^5.13.5": + version "5.13.5" + resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.13.5.tgz#c14f14824f3a37ae0c5ebddbc0034956bc6fec30" + integrity sha512-eMay+Ue1OYXOFMQA5Aau7qbAa/kWHLAyi0McsbPTWssCbGehqkF6CIdPsfVGw6tlO+xPee1hUitphHJNL3xpOQ== + dependencies: + "@babel/runtime" "^7.21.0" + "@mui/base" "5.0.0-beta.4" + "@mui/core-downloads-tracker" "^5.13.4" + "@mui/system" "^5.13.5" + "@mui/types" "^7.2.4" + "@mui/utils" "^5.13.1" + "@types/react-transition-group" "^4.4.6" clsx "^1.2.1" - csstype "^3.1.1" + csstype "^3.1.2" prop-types "^15.8.1" react-is "^18.2.0" react-transition-group "^4.4.5" -"@mui/private-theming@^5.11.9": - version "5.11.9" - resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.11.9.tgz#ce3f7b7fa7de3e8d6b2a3132a22bffd6bfaabe9b" - integrity sha512-XMyVIFGomVCmCm92EvYlgq3zrC9K+J6r7IKl/rBJT2/xVYoRY6uM7jeB+Wxh7kXxnW9Dbqsr2yL3cx6wSD1sAg== +"@mui/private-theming@^5.13.1": + version "5.13.1" + resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.13.1.tgz#c3e9a0b44f9c5a51b92cfcfb660536060cb61ed7" + integrity sha512-HW4npLUD9BAkVppOUZHeO1FOKUJWAwbpy0VQoGe3McUYTlck1HezGHQCfBQ5S/Nszi7EViqiimECVl9xi+/WjQ== dependencies: - "@babel/runtime" "^7.20.13" - "@mui/utils" "^5.11.9" + "@babel/runtime" "^7.21.0" + "@mui/utils" "^5.13.1" prop-types "^15.8.1" -"@mui/styled-engine@^5.11.9": - version "5.11.9" - resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.11.9.tgz#105da848163b993522de0deaada82e10ad357194" - integrity sha512-bkh2CjHKOMy98HyOc8wQXEZvhOmDa/bhxMUekFX5IG0/w4f5HJ8R6+K6nakUUYNEgjOWPYzNPrvGB8EcGbhahQ== +"@mui/styled-engine@^5.13.2": + version "5.13.2" + resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.13.2.tgz#c87bd61c0ab8086d34828b6defe97c02bcd642ef" + integrity sha512-VCYCU6xVtXOrIN8lcbuPmoG+u7FYuOERG++fpY74hPpEWkyFQG97F+/XfTQVYzlR2m7nPjnwVUgATcTCMEaMvw== dependencies: - "@babel/runtime" "^7.20.13" - "@emotion/cache" "^11.10.5" - csstype "^3.1.1" + "@babel/runtime" "^7.21.0" + "@emotion/cache" "^11.11.0" + csstype "^3.1.2" prop-types "^15.8.1" -"@mui/system@^5.11.9": - version "5.11.9" - resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.11.9.tgz#61f83c538cb4bb9383bcfb39734d9d22ae11c3e7" - integrity sha512-h6uarf+l3FO6l75Nf7yO+qDGrIoa1DM9nAMCUFZQsNCDKOInRzcptnm8M1w/Z3gVetfeeGoIGAYuYKbft6KZZA== +"@mui/system@^5.13.5": + version "5.13.5" + resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.13.5.tgz#9f67ea0c4f6974713f90b7b94c999fd3f40f8de3" + integrity sha512-n0gzUxoZ2ZHZgnExkh2Htvo9uW2oakofgPRQrDoa/GQOWyRD0NH9MDszBwOb6AAoXZb+OV5TE7I4LeZ/dzgHYA== dependencies: - "@babel/runtime" "^7.20.13" - "@mui/private-theming" "^5.11.9" - "@mui/styled-engine" "^5.11.9" - "@mui/types" "^7.2.3" - "@mui/utils" "^5.11.9" + "@babel/runtime" "^7.21.0" + "@mui/private-theming" "^5.13.1" + "@mui/styled-engine" "^5.13.2" + "@mui/types" "^7.2.4" + "@mui/utils" "^5.13.1" clsx "^1.2.1" - csstype "^3.1.1" + csstype "^3.1.2" prop-types "^15.8.1" -"@mui/types@^7.2.3": - version "7.2.3" - resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.3.tgz#06faae1c0e2f3a31c86af6f28b3a4a42143670b9" - integrity sha512-tZ+CQggbe9Ol7e/Fs5RcKwg/woU+o8DCtOnccX6KmbBc7YrfqMYEYuaIcXHuhpT880QwNkZZ3wQwvtlDFA2yOw== +"@mui/types@^7.2.4": + version "7.2.4" + resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.4.tgz#b6fade19323b754c5c6de679a38f068fd50b9328" + integrity sha512-LBcwa8rN84bKF+f5sDyku42w1NTxaPgPyYKODsh01U1fVstTClbUoSA96oyRBnSNyEiAVjKm6Gwx9vjR+xyqHA== "@mui/utils@^5.10.3": version "5.11.1" @@ -2820,14 +2858,14 @@ prop-types "^15.8.1" react-is "^18.2.0" -"@mui/utils@^5.11.9": - version "5.11.9" - resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.11.9.tgz#8fab9cf773c63ad916597921860d2344b5d4b706" - integrity sha512-eOJaqzcEs4qEwolcvFAmXGpln+uvouvOS9FUX6Wkrte+4I8rZbjODOBDVNlK+V6/ziTfD4iNKC0G+KfOTApbqg== +"@mui/utils@^5.13.1": + version "5.13.1" + resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.13.1.tgz#86199e46014215f95da046a5ec803f4a39c96eee" + integrity sha512-6lXdWwmlUbEU2jUI8blw38Kt+3ly7xkmV9ljzY4Q20WhsJMWiNry9CX8M+TaP/HbtuyR8XKsdMgQW7h7MM3n3A== dependencies: - "@babel/runtime" "^7.20.13" + "@babel/runtime" "^7.21.0" "@types/prop-types" "^15.7.5" - "@types/react-is" "^16.7.1 || ^17.0.0" + "@types/react-is" "^18.2.0" prop-types "^15.8.1" react-is "^18.2.0" @@ -3019,10 +3057,10 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== -"@popperjs/core@^2.11.6": - version "2.11.6" - resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45" - integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== +"@popperjs/core@^2.11.8": + version "2.11.8" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" + integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" @@ -4204,6 +4242,13 @@ dependencies: "@types/react" "*" +"@types/react-is@^18.2.0": + version "18.2.1" + resolved "https://registry.yarnpkg.com/@types/react-is/-/react-is-18.2.1.tgz#61d01c2a6fc089a53520c0b66996d458fdc46863" + integrity sha512-wyUkmaaSZEzFZivD8F2ftSyAfk6L+DfFliVj/mYdOXbVjRcS87fQJLTnhk6dRZPuJjI+9g6RZJO4PNCngUrmyw== + dependencies: + "@types/react" "*" + "@types/react-qr-reader@^2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@types/react-qr-reader/-/react-qr-reader-2.1.4.tgz#a36f0b83b4402e26c4217d0e8af6b5e2887fc749" @@ -4218,6 +4263,13 @@ dependencies: "@types/react" "*" +"@types/react-transition-group@^4.4.6": + version "4.4.6" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.6.tgz#18187bcda5281f8e10dfc48f0943e2fdf4f75e2e" + integrity sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@18.0.26": version "18.0.26" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.26.tgz#8ad59fc01fef8eaf5c74f4ea392621749f0b7917" @@ -6586,11 +6638,16 @@ cssstyle@^2.3.0: dependencies: cssom "~0.3.6" -csstype@^3.0.2, csstype@^3.1.1: +csstype@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9" integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw== +csstype@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" + integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== + cypress-file-upload@^5.0.8: version "5.0.8" resolved "https://registry.yarnpkg.com/cypress-file-upload/-/cypress-file-upload-5.0.8.tgz#d8824cbeaab798e44be8009769f9a6c9daa1b4a1" @@ -13113,6 +13170,11 @@ stylis@4.1.3: resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.1.3.tgz#fd2fbe79f5fed17c55269e16ed8da14c84d069f7" integrity sha512-GP6WDNWf+o403jrEp9c5jibKavrtLW+/qYGhFxFrG8maXhwTBI7gLLhiBb0o7uFccWN+EOS9aMO6cGHWAO07OA== +stylis@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51" + integrity sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw== + stylus@^0.59.0: version "0.59.0" resolved "https://registry.yarnpkg.com/stylus/-/stylus-0.59.0.tgz#a344d5932787142a141946536d6e24e6a6be7aa6"