diff --git a/cypress/e2e/pages/safeapps.pages.js b/cypress/e2e/pages/safeapps.pages.js index ec67d8cc5f..19f9c525b4 100644 --- a/cypress/e2e/pages/safeapps.pages.js +++ b/cypress/e2e/pages/safeapps.pages.js @@ -64,7 +64,7 @@ export const testBooleanValue3 = '3 testBooleanValue' export const transfer2AssetsStr = 'Transfer 2 assets' export const testTransfer1 = '1 transfer' -export const testTransfer2 = '2 transfer' +export const testTransfer2 = '2 MetaMultiSigWallet: transfer' export const nativeTransfer2 = '2 native transfer' export const nativeTransfer1 = '1 native transfer' diff --git a/src/components/common/ErrorBoundary/index.tsx b/src/components/common/ErrorBoundary/index.tsx index f25c1bbcfa..bb410b7c98 100644 --- a/src/components/common/ErrorBoundary/index.tsx +++ b/src/components/common/ErrorBoundary/index.tsx @@ -1,5 +1,4 @@ import { Typography, Link } from '@mui/material' -import type { FallbackRender } from '@sentry/react' import { HELP_CENTER_URL, IS_PRODUCTION } from '@/config/constants' import { AppRoutes } from '@/config/routes' @@ -8,8 +7,12 @@ import WarningIcon from '@/public/images/notifications/warning.svg' import css from '@/components/common/ErrorBoundary/styles.module.css' import CircularIcon from '../icons/CircularIcon' import ExternalLink from '../ExternalLink' +interface ErrorBoundaryProps { + error: Error + componentStack: string +} -const ErrorBoundary: FallbackRender = ({ error, componentStack }) => { +const ErrorBoundary = ({ error, componentStack }: ErrorBoundaryProps) => { return (
diff --git a/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx b/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx index c6b1d2b26a..20a26981c8 100644 --- a/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx +++ b/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx @@ -16,6 +16,7 @@ export const SafeTxHashDataRow = ({ }) => { const chainId = useChainId() const safeAddress = useSafeAddress() + const domainHash = TypedDataEncoder.hashDomain({ chainId, verifyingContract: safeAddress, diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx b/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx index 0672be131c..5110562ff6 100644 --- a/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx +++ b/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx @@ -71,7 +71,6 @@ export const Multisend = ({ txData, compact = false }: MultisendProps): ReactEle if (!multiSendTransactions) { return null } - return ( <> diff --git a/src/components/tx-flow/SafeTxProvider.tsx b/src/components/tx-flow/SafeTxProvider.tsx index b964f7546c..f83c86663a 100644 --- a/src/components/tx-flow/SafeTxProvider.tsx +++ b/src/components/tx-flow/SafeTxProvider.tsx @@ -9,7 +9,7 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { useCurrentChain } from '@/hooks/useChains' import { prependSafeToL2Migration } from '@/utils/transactions' -export const SafeTxContext = createContext<{ +export type SafeTxContextParams = { safeTx?: SafeTransaction setSafeTx: Dispatch> @@ -28,7 +28,9 @@ export const SafeTxContext = createContext<{ setSafeTxGas: Dispatch> recommendedNonce?: number -}>({ +} + +export const SafeTxContext = createContext({ setSafeTx: () => {}, setSafeMessage: () => {}, setSafeTxError: () => {}, @@ -81,7 +83,10 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => if (safeTx.data.nonce === finalNonce && safeTx.data.safeTxGas === finalSafeTxGas) return createTx({ ...safeTx.data, safeTxGas: String(finalSafeTxGas) }, finalNonce) - .then(setSafeTx) + .then((tx) => { + console.log('SafeTxProvider: Updated tx with nonce and safeTxGas', tx) + setSafeTx(tx) + }) .catch(setSafeTxError) }, [isSigned, finalNonce, finalSafeTxGas, safeTx?.data]) diff --git a/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx index 7806b28b97..7c148cc2d2 100644 --- a/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx +++ b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx @@ -1,6 +1,5 @@ import { useCurrentChain } from '@/hooks/useChains' import { useContext, useEffect } from 'react' -import { Typography, Divider, Box, SvgIcon, Paper } from '@mui/material' import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import useSafeInfo from '@/hooks/useSafeInfo' @@ -11,11 +10,7 @@ import { upsertAddressBookEntries } from '@/store/addressBookSlice' import { SafeTxContext } from '../../SafeTxProvider' import type { AddOwnerFlowProps } from '.' import type { ReplaceOwnerFlowProps } from '../ReplaceOwner' -import { OwnerList } from '../../common/OwnerList' -import MinusIcon from '@/public/images/common/minus.svg' -import EthHashInfo from '@/components/common/EthHashInfo' -import commonCss from '@/components/tx-flow/common/styles.module.css' -import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' +import { SettingsChangeContext } from './context' export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwnerFlowProps }) => { const dispatch = useAppDispatch() @@ -57,33 +52,8 @@ export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwn } return ( - - {params.removedOwner && ( - palette.warning.background, p: 2 }}> - - - Previous signer - - - - )} - - - - - - Any transaction requires the confirmation of: - - {threshold} out of {safe.owners.length + (removedOwner ? 0 : 1)} signers - - - - + + + ) } diff --git a/src/components/tx-flow/flows/AddOwner/context.ts b/src/components/tx-flow/flows/AddOwner/context.ts new file mode 100644 index 0000000000..ce829bcb8b --- /dev/null +++ b/src/components/tx-flow/flows/AddOwner/context.ts @@ -0,0 +1,7 @@ +import { type Context, createContext } from 'react' +import { type AddOwnerFlowProps } from '.' +import { type ReplaceOwnerFlowProps } from '../ReplaceOwner' + +type SettingsChange = Context + +export const SettingsChangeContext: SettingsChange = createContext({} as AddOwnerFlowProps | ReplaceOwnerFlowProps) diff --git a/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx b/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx index 8fe4425152..48e7a2a238 100644 --- a/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx +++ b/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx @@ -1,6 +1,5 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { useContext, useEffect } from 'react' -import { Box, Divider, Typography } from '@mui/material' import { createUpdateThresholdTx } from '@/services/tx/tx-sender' import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics' @@ -9,8 +8,7 @@ import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import { ChangeThresholdFlowFieldNames } from '@/components/tx-flow/flows/ChangeThreshold' import type { ChangeThresholdFlowProps } from '@/components/tx-flow/flows/ChangeThreshold' -import commonCss from '@/components/tx-flow/common/styles.module.css' -import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' +import { ChangeThresholdReviewContext } from './context' const ReviewChangeThreshold = ({ params }: { params: ChangeThresholdFlowProps }) => { const { safe } = useSafeInfo() @@ -28,22 +26,9 @@ const ReviewChangeThreshold = ({ params }: { params: ChangeThresholdFlowProps }) } return ( - - - -
- - Any transaction will require the confirmation of: - - - - {newThreshold} out of {safe.owners.length} signer(s) - -
- - - -
+ + + ) } diff --git a/src/components/tx-flow/flows/ChangeThreshold/context.tsx b/src/components/tx-flow/flows/ChangeThreshold/context.tsx new file mode 100644 index 0000000000..037a1fe6b7 --- /dev/null +++ b/src/components/tx-flow/flows/ChangeThreshold/context.tsx @@ -0,0 +1,5 @@ +import { createContext } from 'react' + +export const ChangeThresholdReviewContext = createContext({ + newThreshold: 0, +}) diff --git a/src/components/tx-flow/flows/ConfirmBatch/index.tsx b/src/components/tx-flow/flows/ConfirmBatch/index.tsx index 27e53d211f..a0159b21e8 100644 --- a/src/components/tx-flow/flows/ConfirmBatch/index.tsx +++ b/src/components/tx-flow/flows/ConfirmBatch/index.tsx @@ -8,7 +8,6 @@ import { OperationType } from '@safe-global/safe-core-sdk-types' import TxLayout from '../../common/TxLayout' import BatchIcon from '@/public/images/common/batch.svg' import { useDraftBatch } from '@/hooks/useDraftBatch' -import BatchTxList from '@/components/batch/BatchSidebar/BatchTxList' type ConfirmBatchProps = { onSubmit: () => void @@ -32,11 +31,7 @@ const ConfirmBatch = ({ onSubmit }: ConfirmBatchProps): ReactElement => { createMultiSendCallOnlyTx(calls).then(setSafeTx).catch(setSafeTxError) }, [batchTxs, setSafeTx, setSafeTxError]) - return ( - - - - ) + return } const ConfirmBatchFlow = (props: ConfirmBatchProps) => { diff --git a/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx b/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx index c9e4587cb9..c62c776a3b 100644 --- a/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx +++ b/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx @@ -4,10 +4,10 @@ import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sd import useSafeInfo from '@/hooks/useSafeInfo' import { useChainId } from '@/hooks/useChainId' import useWallet from '@/hooks/wallets/useWallet' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { isExecutable, isMultisigExecutionInfo, isSignableBy } from '@/utils/transaction-guards' import { createExistingTx } from '@/services/tx/tx-sender' import { SafeTxContext } from '../../SafeTxProvider' +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' type ConfirmProposedTxProps = { txSummary: TransactionSummary diff --git a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx index e057ea107f..71db742fce 100644 --- a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx +++ b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx @@ -34,6 +34,8 @@ import WalletRejectionError from '@/components/tx/SignOrExecuteForm/WalletReject import commonCss from '@/components/tx-flow/common/styles.module.css' import { BlockaidBalanceChanges } from '@/components/tx/security/blockaid/BlockaidBalanceChange' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' +import { useGetTransactionDetailsQuery } from '@/store/api/gateway' +import { skipToken } from '@reduxjs/toolkit/query' export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlowProps }): ReactElement | null { // Form state @@ -52,6 +54,8 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo const recovery = data && selectDelayModifierByRecoverer(data, wallet?.address ?? '') const [, executionValidationError] = useIsValidRecoveryExecTransactionFromModule(recovery?.address, safeTx) + const { data: txDetails } = useGetTransactionDetailsQuery(skipToken) + // Proposal const newThreshold = Number(params[RecoverAccountFlowFields.threshold]) const newOwners = params[RecoverAccountFlowFields.owners] @@ -127,7 +131,7 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo - + diff --git a/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx index 4acae50f16..a874915640 100644 --- a/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx +++ b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx @@ -3,15 +3,26 @@ import * as web3 from '@/hooks/wallets/web3' import * as useSafeInfo from '@/hooks/useSafeInfo' import { render, screen } from '@/tests/test-utils' import * as execThroughRoleHooks from '@/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { SafeAppAccessPolicyTypes } from '@safe-global/safe-gateway-typescript-sdk' import ReviewSignMessageOnChain from '@/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain' import { JsonRpcProvider, zeroPadValue } from 'ethers' import { act } from '@testing-library/react' +import type { SafeTxContextParams } from '../../SafeTxProvider' +import { SafeTxContext } from '../../SafeTxProvider' +import { createSafeTx } from '@/tests/builders/safeTx' +import * as hooks from '@/components/tx/SignOrExecuteForm/hooks' jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([]) - describe('ReviewSignMessageOnChain', () => { test('can handle messages with EIP712Domain type in the JSON-RPC payload', async () => { + jest.spyOn(hooks, 'useProposeTx').mockReturnValue([ + { + txInfo: {}, + } as TransactionDetails, + undefined, + false, + ]) jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => new JsonRpcProvider()) jest.spyOn(useSafeInfo, 'default').mockImplementation( () => @@ -27,66 +38,74 @@ describe('ReviewSignMessageOnChain', () => { await act(async () => { render( - , + + + , ) }) diff --git a/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx b/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx index 8c28146da8..80f51861f4 100644 --- a/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx +++ b/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx @@ -23,7 +23,7 @@ import useOnboard from '@/hooks/wallets/useOnboard' import { asError } from '@/services/exceptions/utils' import TxCard from '@/components/tx-flow/common/TxCard' import { TxModalContext } from '@/components/tx-flow' -import { type SubmitCallback } from '@/components/tx/SignOrExecuteForm' +import { type SubmitCallback } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' import { TX_EVENTS, TX_TYPES } from '@/services/analytics/events/transactions' import { isWalletRejection } from '@/utils/wallets' import { safeParseUnits } from '@/utils/formatters' diff --git a/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx b/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx index d7ec1e3ea1..0c161bff10 100644 --- a/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx +++ b/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx @@ -1,6 +1,6 @@ import { useContext, useEffect, useMemo } from 'react' import useBalances from '@/hooks/useBalances' -import SignOrExecuteForm, { type SubmitCallback } from '@/components/tx/SignOrExecuteForm' +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock' import SendToBlock from '@/components/tx/SendToBlock' import { createTokenTransferParams } from '@/services/tx/tokenTransferParams' @@ -8,6 +8,7 @@ import { createTx } from '@/services/tx/tx-sender' import type { TokenTransferParams } from '.' import { SafeTxContext } from '../../SafeTxProvider' import { safeParseUnits } from '@/utils/formatters' +import type { SubmitCallback } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' const ReviewTokenTransfer = ({ params, diff --git a/src/components/tx/DecodedTx/index.test.tsx b/src/components/tx/DecodedTx/index.test.tsx index 078c5c8895..0cf81f15f1 100644 --- a/src/components/tx/DecodedTx/index.test.tsx +++ b/src/components/tx/DecodedTx/index.test.tsx @@ -2,12 +2,110 @@ import { fireEvent, render } from '@/tests/test-utils' import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' import DecodedTx from '.' import { waitFor } from '@testing-library/react' +import { createMockTransactionDetails } from '@/tests/transactions' +import { + DetailedExecutionInfoType, + SettingsInfoType, + TransactionInfoType, +} from '@safe-global/safe-gateway-typescript-sdk' import type { DecodedDataResponse } from '@safe-global/safe-gateway-typescript-sdk' +const txDetails = createMockTransactionDetails({ + txInfo: { + type: TransactionInfoType.SETTINGS_CHANGE, + humanDescription: 'Add new owner 0xd8dA...6045 with threshold 1', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + }, + ], + }, + settingsInfo: { + type: SettingsInfoType.ADD_OWNER, + owner: { + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + name: 'Nevinha', + logoUri: 'http://something.com', + }, + threshold: 1, + }, + }, + txData: { + hexData: + '0x0d582f13000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000000000000000001', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + }, + ], + }, + to: { + value: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', + name: '', + }, + value: '0', + operation: 0, + trustedDelegateCallTarget: false, + addressInfoIndex: { + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045': { + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + name: 'MetaMultiSigWallet', + }, + }, + }, + detailedExecutionInfo: { + type: DetailedExecutionInfoType.MULTISIG, + submittedAt: 1726064794013, + nonce: 4, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + name: 'MetaMultiSigWallet', + }, + safeTxHash: '0x96a96c11b8d013ff5d7a6ce960b22e961046cfa42eff422ac71c1daf6adef2e0', + signers: [ + { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: '', + }, + ], + confirmationsRequired: 1, + confirmations: [], + rejectors: [], + trusted: false, + proposer: { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: '', + }, + }, +}) describe('DecodedTx', () => { it('should render a native transfer', async () => { const result = render( { fireEvent.click(result.getByText('Advanced details')) await waitFor(() => { - expect(result.queryByText('safeTxGas:')).toBeInTheDocument() - expect(result.queryByText('Raw data:')).toBeInTheDocument() + expect(result.queryAllByText('safeTxGas:').length).toBeGreaterThan(0) + expect(result.queryAllByText('Raw data:').length).toBeGreaterThan(0) }) }) @@ -95,6 +193,9 @@ describe('DecodedTx', () => { it('should render an ERC20 transfer', async () => { const result = render( { }, ], }} - showMethodCall />, ) @@ -134,12 +234,12 @@ describe('DecodedTx', () => { await waitFor(() => { expect(result.queryByText('transfer')).toBeInTheDocument() - expect(result.queryByText('Parameters')).toBeInTheDocument() + expect(result.queryAllByText('Parameters').length).toBeGreaterThan(0) expect(result.queryByText('to')).toBeInTheDocument() - expect(result.queryByText('address')).toBeInTheDocument() + expect(result.queryAllByText('address').length).toBeGreaterThan(0) expect(result.queryByText('0x474e...78C8')).toBeInTheDocument() expect(result.queryByText('value')).toBeInTheDocument() - expect(result.queryByText('uint256')).toBeInTheDocument() + expect(result.queryAllByText('uint256').length).toBeGreaterThan(0) expect(result.queryByText('16745726664999765048')).toBeInTheDocument() }) }) @@ -228,6 +328,7 @@ describe('DecodedTx', () => { ], }} showMethodCall + showMultisend />, ) @@ -237,6 +338,7 @@ describe('DecodedTx', () => { it('should render a function call without parameters', async () => { const result = render( { }, } as SafeTransaction } + showMultisend={false} decodedData={{ method: 'deposit', parameters: [], diff --git a/src/components/tx/DecodedTx/index.tsx b/src/components/tx/DecodedTx/index.tsx index 09912275da..e213290c02 100644 --- a/src/components/tx/DecodedTx/index.tsx +++ b/src/components/tx/DecodedTx/index.tsx @@ -1,11 +1,14 @@ import { type SyntheticEvent, type ReactElement, memo } from 'react' -import { isCustomTxInfo } from '@/utils/transaction-guards' -import { Accordion, AccordionDetails, AccordionSummary, Box, Skeleton, Stack } from '@mui/material' +import { + isCustomTxInfo, + isMultisigDetailedExecutionInfo, + isNativeTokenTransfer, + isTransferTxInfo, +} from '@/utils/transaction-guards' +import { Accordion, AccordionDetails, AccordionSummary, Box, Stack } from '@mui/material' import { OperationType, type SafeTransaction } from '@safe-global/safe-core-sdk-types' -import type { DecodedDataResponse } from '@safe-global/safe-gateway-typescript-sdk' +import type { DecodedDataResponse, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { Operation } from '@safe-global/safe-gateway-typescript-sdk' -import useChainId from '@/hooks/useChainId' -import ErrorMessage from '../ErrorMessage' import Summary, { PartialSummary } from '@/components/transactions/TxDetails/Summary' import { trackEvent, MODALS_EVENTS } from '@/services/analytics' import Multisend from '@/components/transactions/TxDetails/TxData/DecodedData/Multisend' @@ -13,13 +16,11 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData' import accordionCss from '@/styles/accordion.module.css' import HelpToolTip from './HelpTooltip' -import { useGetTransactionDetailsQuery } from '@/store/api/gateway' -import { skipToken } from '@reduxjs/toolkit/query/react' -import { asError } from '@/services/exceptions/utils' type DecodedTxProps = { tx?: SafeTransaction txId?: string + txDetails?: TransactionDetails showMultisend?: boolean decodedData?: DecodedDataResponse showMethodCall?: boolean @@ -36,33 +37,24 @@ export const Divider = () => ( const DecodedTx = ({ tx, - txId, + txDetails, decodedData, showMultisend = true, showMethodCall = false, }: DecodedTxProps): ReactElement => { - const chainId = useChainId() - const isMultisend = !!decodedData?.parameters?.[0]?.valueDecoded + const isMultisend = decodedData?.parameters && !!decodedData?.parameters[0]?.valueDecoded const isMethodCallInAdvanced = !showMethodCall || (isMultisend && showMultisend) - const { - data: txDetails, - error: txDetailsError, - isLoading: txDetailsLoading, - } = useGetTransactionDetailsQuery( - chainId && txId - ? { - chainId, - txId, - } - : skipToken, - ) - const onChangeExpand = (_: SyntheticEvent, expanded: boolean) => { trackEvent({ ...MODALS_EVENTS.TX_DETAILS, label: expanded ? 'Open' : 'Close' }) } const addressInfoIndex = txDetails?.txData?.addressInfoIndex + const isCreation = + txDetails && + isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) && + txDetails.detailedExecutionInfo.confirmations.length === 0 + const txData = { dataDecoded: decodedData, to: { value: tx?.data.to || '' }, @@ -76,11 +68,12 @@ const DecodedTx = ({ let toInfo = tx && { value: tx.data.to, } - if (txDetails && isCustomTxInfo(txDetails.txInfo)) { - toInfo = txDetails.txInfo.to + if (txDetails && isCustomTxInfo(txDetails?.txInfo)) { + toInfo = txDetails?.txInfo.to } const decodedDataBlock = + const showDecodedData = isMethodCallInAdvanced && decodedData?.method return ( @@ -103,18 +96,20 @@ const DecodedTx = ({ {isMethodCallInAdvanced && decodedData?.method} - {!showMethodCall && !decodedData?.method && Number(tx?.data.value) > 0 && 'native transfer'} + {txDetails && + isTransferTxInfo(txDetails.txInfo) && + isNativeTokenTransfer(txDetails.txInfo.transferInfo) && + 'native transfer'} - - {isMethodCallInAdvanced && decodedData?.method && ( + {showDecodedData && ( <> {decodedDataBlock} )} - {txDetails ? ( + {txDetails && !showDecodedData && !isCreation ? ( )} - - {txDetailsLoading && } - - {txDetailsError && ( - Failed loading all transaction details - )} diff --git a/src/components/tx/SignOrExecuteForm/DelegateForm.tsx b/src/components/tx/SignOrExecuteForm/DelegateForm.tsx index 37b222ef98..eb02a623bf 100644 --- a/src/components/tx/SignOrExecuteForm/DelegateForm.tsx +++ b/src/components/tx/SignOrExecuteForm/DelegateForm.tsx @@ -9,7 +9,7 @@ import commonCss from '@/components/tx-flow/common/styles.module.css' import ErrorMessage from '@/components/tx/ErrorMessage' import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext' import { useTxActions } from '@/components/tx/SignOrExecuteForm/hooks' -import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm/index' +import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' import useWallet from '@/hooks/wallets/useWallet' import { Errors, trackError } from '@/services/exceptions' import { asError } from '@/services/exceptions/utils' diff --git a/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx b/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx index ea19347871..398614fdf9 100644 --- a/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx +++ b/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx @@ -15,7 +15,7 @@ import { useRelaysBySafe } from '@/hooks/useRemainingRelays' import useWalletCanRelay from '@/hooks/useWalletCanRelay' import { ExecutionMethod, ExecutionMethodSelector } from '../ExecutionMethodSelector' import { hasRemainingRelays } from '@/utils/relaying' -import type { SignOrExecuteProps } from '.' +import type { SignOrExecuteProps } from './SignOrExecuteForm' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { TxModalContext } from '@/components/tx-flow' import { SuccessScreenFlow } from '@/components/tx-flow/flows' @@ -48,6 +48,7 @@ export const ExecuteForm = ({ isExecutionLoop: ReturnType txActions: ReturnType txSecurity: ReturnType + isCreation?: boolean safeTx?: SafeTransaction }): ReactElement => { // Form state diff --git a/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx b/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx index ee0382dcd5..3563336048 100644 --- a/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx +++ b/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx @@ -143,6 +143,7 @@ describe('ExecuteThroughRoleForm', () => { const { findByText, getByText } = render( , @@ -170,7 +171,9 @@ describe('ExecuteThroughRoleForm', () => { const onSubmit = jest.fn() - const { findByText } = render() + const { findByText } = render( + , + ) fireEvent.click(await findByText('Execute')) diff --git a/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/index.tsx b/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/index.tsx index 39acb5a1d1..b8fcc7a600 100644 --- a/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/index.tsx +++ b/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/index.tsx @@ -9,7 +9,7 @@ import { useCurrentChain } from '@/hooks/useChains' import { getTxOptions } from '@/utils/transactions' import CheckWallet from '@/components/common/CheckWallet' -import type { SignOrExecuteProps } from '..' +import type { SignOrExecuteProps } from '../SignOrExecuteForm' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { TxModalContext } from '@/components/tx-flow' import { SuccessScreenFlow } from '@/components/tx-flow/flows' diff --git a/src/components/tx/SignOrExecuteForm/SignForm.tsx b/src/components/tx/SignOrExecuteForm/SignForm.tsx index 850432f10a..3e16bc37fe 100644 --- a/src/components/tx/SignOrExecuteForm/SignForm.tsx +++ b/src/components/tx/SignOrExecuteForm/SignForm.tsx @@ -7,7 +7,7 @@ import { trackError, Errors } from '@/services/exceptions' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import CheckWallet from '@/components/common/CheckWallet' import { useAlreadySigned, useTxActions } from './hooks' -import type { SignOrExecuteProps } from '.' +import type { SignOrExecuteProps } from './SignOrExecuteForm' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { TxModalContext } from '@/components/tx-flow' import commonCss from '@/components/tx-flow/common/styles.module.css' @@ -34,6 +34,7 @@ export const SignForm = ({ isOwner: ReturnType txActions: ReturnType txSecurity: ReturnType + isCreation?: boolean safeTx?: SafeTransaction }): ReactElement => { // Form state diff --git a/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx b/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx new file mode 100644 index 0000000000..98bf41fada --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx @@ -0,0 +1,242 @@ +import DelegateForm from '@/components/tx/SignOrExecuteForm/DelegateForm' +import CounterfactualForm from '@/features/counterfactual/CounterfactualForm' +import { useIsWalletDelegate } from '@/hooks/useDelegates' +import useSafeInfo from '@/hooks/useSafeInfo' +import { type ReactElement, type ReactNode, useState, useContext, useCallback } from 'react' +import madProps from '@/utils/mad-props' +import ExecuteCheckbox from '../ExecuteCheckbox' +import { useImmediatelyExecutable, useValidateNonce } from './hooks' +import ExecuteForm from './ExecuteForm' +import SignForm from './SignForm' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import ErrorMessage from '../ErrorMessage' +import TxChecks from './TxChecks' +import TxCard from '@/components/tx-flow/common/TxCard' +import ConfirmationTitle, { ConfirmationTitleTypes } from '@/components/tx/SignOrExecuteForm/ConfirmationTitle' +import { useAppSelector } from '@/store' +import { selectSettings } from '@/store/settingsSlice' +import UnknownContractError from './UnknownContractError' +import { ErrorBoundary } from '@sentry/react' +import ApprovalEditor from '../ApprovalEditor' +import { isDelegateCall } from '@/services/tx/tx-sender/sdk' +import { getTransactionTrackingType } from '@/services/analytics/tx-tracking' +import { TX_EVENTS } from '@/services/analytics/events/transactions' +import { trackEvent } from '@/services/analytics' +import useChainId from '@/hooks/useChainId' +import ExecuteThroughRoleForm from './ExecuteThroughRoleForm' +import { findAllowingRole, findMostLikelyRole, useRoles } from './ExecuteThroughRoleForm/hooks' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { BlockaidBalanceChanges } from '../security/blockaid/BlockaidBalanceChange' +import { Blockaid } from '../security/blockaid' + +import { MigrateToL2Information } from './MigrateToL2Information' +import { extractMigrationL2MasterCopyAddress } from '@/utils/transactions' + +import { useLazyGetTransactionDetailsQuery } from '@/store/api/gateway' +import { useApprovalInfos } from '../ApprovalEditor/hooks/useApprovalInfos' + +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import NetworkWarning from '@/components/new-safe/create/NetworkWarning' +import ConfirmationView from '../confirmation-views' + +export type SubmitCallback = (txId: string, isExecuted?: boolean) => void + +export type SignOrExecuteProps = { + txId?: string + onSubmit?: SubmitCallback + children?: ReactNode + isExecutable?: boolean + isRejection?: boolean + isBatch?: boolean + isBatchable?: boolean + onlyExecute?: boolean + disableSubmit?: boolean + origin?: string + showMethodCall?: boolean +} + +const trackTxEvents = ( + details: TransactionDetails | undefined, + isCreation: boolean, + isExecuted: boolean, + isRoleExecution: boolean, + isDelegateCreation: boolean, +) => { + const creationEvent = isRoleExecution + ? TX_EVENTS.CREATE_VIA_ROLE + : isDelegateCreation + ? TX_EVENTS.CREATE_VIA_DELEGATE + : TX_EVENTS.CREATE + const executionEvent = isRoleExecution ? TX_EVENTS.EXECUTE_VIA_ROLE : TX_EVENTS.EXECUTE + const event = isCreation ? creationEvent : isExecuted ? executionEvent : TX_EVENTS.CONFIRM + const txType = getTransactionTrackingType(details) + trackEvent({ ...event, label: txType }) + + // Immediate execution on creation + if (isCreation && isExecuted) { + trackEvent({ ...executionEvent, label: txType }) + } +} + +export const SignOrExecuteForm = ({ + chainId, + safeTx, + safeTxError, + onSubmit, + isCreation, + ...props +}: SignOrExecuteProps & { + chainId: ReturnType + safeTx: ReturnType + safeTxError: ReturnType + isCreation?: boolean + txDetails?: TransactionDetails +}): ReactElement => { + const { transactionExecution } = useAppSelector(selectSettings) + const [shouldExecute, setShouldExecute] = useState(transactionExecution) + const isNewExecutableTx = useImmediatelyExecutable() && isCreation + const isCorrectNonce = useValidateNonce(safeTx) + + console.log(props.txDetails) + + // TODO: move it to the confirmation view + // const showTxDetails = + // !isAnyStakingTxInfo(txDetails.txInfo) && + // !isOrderTxInfo(txDetails.txInfo) + + const isBatchable = props.isBatchable !== false && safeTx && !isDelegateCall(safeTx) + + const isDelegate = useIsWalletDelegate() + + const [trigger] = useLazyGetTransactionDetailsQuery() + const [readableApprovals] = useApprovalInfos({ safeTransaction: safeTx }) + const isApproval = readableApprovals && readableApprovals.length > 0 + const { safe } = useSafeInfo() + const isSafeOwner = useIsSafeOwner() + const isCounterfactualSafe = !safe.deployed + const multiChainMigrationTarget = extractMigrationL2MasterCopyAddress(safeTx) + const isMultiChainMigration = !!multiChainMigrationTarget + + // Check if a Zodiac Roles mod is enabled and if the user is a member of any role that allows the transaction + const roles = useRoles( + !isCounterfactualSafe && isCreation && !(isNewExecutableTx && isSafeOwner) ? safeTx : undefined, + ) + const allowingRole = findAllowingRole(roles) + const mostLikelyRole = findMostLikelyRole(roles) + const canExecuteThroughRole = !!allowingRole || (!!mostLikelyRole && !isSafeOwner) + const preferThroughRole = canExecuteThroughRole && !isSafeOwner // execute through role if a non-owner role member wallet is connected + + // If checkbox is checked and the transaction is executable, execute it, otherwise sign it + const canExecute = isCorrectNonce && (props.isExecutable || isNewExecutableTx) + const willExecute = (props.onlyExecute || shouldExecute) && canExecute && !preferThroughRole + const willExecuteThroughRole = + (props.onlyExecute || shouldExecute) && canExecuteThroughRole && (!canExecute || preferThroughRole) + + const onFormSubmit = useCallback( + async (txId: string, isExecuted = false, isRoleExecution = false, isDelegateCreation = false) => { + onSubmit?.(txId, isExecuted) + + const { data: details } = await trigger({ chainId, txId }) + // Track tx event + trackTxEvents(details, !!isCreation, isExecuted, isRoleExecution, isDelegateCreation) + }, + [chainId, isCreation, onSubmit, trigger], + ) + + const onRoleExecutionSubmit = useCallback( + (txId, isExecuted) => onFormSubmit(txId, isExecuted, true), + [onFormSubmit], + ) + + const onDelegateFormSubmit = useCallback( + (txId, isExecuted) => onFormSubmit(txId, isExecuted, false, true), + [onFormSubmit], + ) + + return ( + <> + + {props.children} + {isMultiChainMigration && } + + + {!props.isRejection && ( + Error parsing data
}> + {isApproval && } + + )} + + + {!isCounterfactualSafe && !props.isRejection && } + + + {!isCounterfactualSafe && !props.isRejection && } + + + + + {safeTxError && ( + + This transaction will most likely fail. To save gas costs, avoid confirming the transaction. + + )} + + {(canExecute || canExecuteThroughRole) && !props.onlyExecute && !isCounterfactualSafe && !isDelegate && ( + + )} + + + + {!isMultiChainMigration && } + + + + {isCounterfactualSafe && !isDelegate && ( + + )} + {!isCounterfactualSafe && willExecute && !isDelegate && ( + + )} + {!isCounterfactualSafe && willExecuteThroughRole && ( + + )} + {!isCounterfactualSafe && !willExecute && !willExecuteThroughRole && !isDelegate && ( + + )} + + {isDelegate && } + + + ) +} + +const useSafeTx = () => useContext(SafeTxContext).safeTx +const useSafeTxError = () => useContext(SafeTxContext).safeTxError + +export default madProps(SignOrExecuteForm, { + chainId: useChainId, + safeTx: useSafeTx, + safeTxError: useSafeTxError, +}) diff --git a/src/components/tx/SignOrExecuteForm/SignOrExecuteSkeleton.tsx b/src/components/tx/SignOrExecuteForm/SignOrExecuteSkeleton.tsx new file mode 100644 index 0000000000..d6c6c20f71 --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/SignOrExecuteSkeleton.tsx @@ -0,0 +1,13 @@ +import LoadingSpinner, { SpinnerStatus } from '@/components/new-safe/create/steps/StatusStep/LoadingSpinner' +import TxCard from '@/components/tx-flow/common/TxCard' +import { Box } from '@mui/material' + +const SignOrExecuteSkeleton = () => ( + + + + + +) + +export default SignOrExecuteSkeleton diff --git a/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx b/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx index 60141794fd..3b85d1baee 100644 --- a/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx +++ b/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx @@ -31,9 +31,16 @@ describe('ExecuteForm', () => { const defaultProps = { onSubmit: jest.fn(), isOwner: true, + txId: '0x123123', isExecutionLoop: false, relays: [undefined, undefined, false] as AsyncResult, - txActions: { signTx: jest.fn(), addToBatch: jest.fn(), executeTx: jest.fn(), signDelegateTx: jest.fn() }, + txActions: { + proposeTx: jest.fn(), + signTx: jest.fn(), + addToBatch: jest.fn(), + executeTx: jest.fn(), + signDelegateTx: jest.fn(), + }, txSecurity: defaultSecurityContextValues, } @@ -107,7 +114,13 @@ describe('ExecuteForm', () => { const { getByText } = render( , ) @@ -138,7 +151,13 @@ describe('ExecuteForm', () => { {...defaultProps} safeTx={safeTransaction} onSubmit={jest.fn()} - txActions={{ signTx: jest.fn(), addToBatch: jest.fn(), executeTx: mockExecuteTx, signDelegateTx: jest.fn() }} + txActions={{ + proposeTx: jest.fn(), + signTx: jest.fn(), + addToBatch: jest.fn(), + executeTx: mockExecuteTx, + signDelegateTx: jest.fn(), + }} />, ) @@ -158,7 +177,13 @@ describe('ExecuteForm', () => { , ) diff --git a/src/components/tx/SignOrExecuteForm/__tests__/SignForm.test.tsx b/src/components/tx/SignOrExecuteForm/__tests__/SignForm.test.tsx index ea18ea7c9e..0b4f58851a 100644 --- a/src/components/tx/SignOrExecuteForm/__tests__/SignForm.test.tsx +++ b/src/components/tx/SignOrExecuteForm/__tests__/SignForm.test.tsx @@ -24,8 +24,15 @@ describe('SignForm', () => { const defaultProps = { onSubmit: jest.fn(), + txId: '0x01231', isOwner: true, - txActions: { signTx: jest.fn(), addToBatch: jest.fn(), executeTx: jest.fn(), signDelegateTx: jest.fn() }, + txActions: { + proposeTx: jest.fn(), + signTx: jest.fn(), + addToBatch: jest.fn(), + executeTx: jest.fn(), + signDelegateTx: jest.fn(), + }, txSecurity: defaultSecurityContextValues, } @@ -70,7 +77,13 @@ describe('SignForm', () => { , ) @@ -90,7 +103,13 @@ describe('SignForm', () => { , ) @@ -133,7 +152,13 @@ describe('SignForm', () => { safeTx={safeTransaction} isBatchable isCreation - txActions={{ signTx: jest.fn(), addToBatch: mockAddToBatch, executeTx: jest.fn(), signDelegateTx: jest.fn() }} + txActions={{ + proposeTx: jest.fn(), + signTx: jest.fn(), + addToBatch: mockAddToBatch, + executeTx: jest.fn(), + signDelegateTx: jest.fn(), + }} />, ) diff --git a/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecute.test.tsx b/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecute.test.tsx index 7008fa0c19..44d7769f9d 100644 --- a/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecute.test.tsx +++ b/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecute.test.tsx @@ -1,11 +1,10 @@ -import { SignOrExecuteForm } from '@/components/tx/SignOrExecuteForm' -import * as hooks from '@/components/tx/SignOrExecuteForm/hooks' -import * as execThroughRoleHooks from '@/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks' -import { safeTxBuilder } from '@/tests/builders/safeTx' +import SignOrExecute from '../index' import { render } from '@/tests/test-utils' -import { fireEvent } from '@testing-library/react' -import { encodeBytes32String } from 'ethers' -import { Status } from 'zodiac-roles-deployments' +import * as hooks from '../hooks' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeTxContextParams } from '@/components/tx-flow/SafeTxProvider' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { createSafeTx } from '@/tests/builders/safeTx' let isSafeOwner = true // mock useIsSafeOwner @@ -14,215 +13,65 @@ jest.mock('@/hooks/useIsSafeOwner', () => ({ default: jest.fn(() => isSafeOwner), })) +// Mock proposeTx +jest.mock('@/services/tx/proposeTransaction', () => ({ + __esModule: true, + default: jest.fn(() => Promise.resolve({ txId: '123' })), +})) + describe('SignOrExecute', () => { beforeEach(() => { isSafeOwner = true + jest.clearAllMocks() }) - it('should display a safeTxError', () => { - const { getByText } = render( - , - ) - - expect( - getByText('This transaction will most likely fail. To save gas costs, avoid confirming the transaction.'), - ).toBeInTheDocument() - }) - - describe('Existing transaction', () => { - it('should display radio options to sign or execute if both are possible', () => { - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - - const { getByText } = render( - , - ) - - expect(getByText('Would you like to execute the transaction immediately?')).toBeInTheDocument() - }) - }) - - describe('New transaction', () => { - it('should display radio options to sign or execute if both are possible', () => { - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(true) - - const { getByText } = render( - , - ) - - expect(getByText('Would you like to execute the transaction immediately?')).toBeInTheDocument() - }) - - it('should offer to execute through a role if the user is a role member and the transaction is executable through the role', () => { - jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_OK]) - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) - - const { queryByTestId } = render( - , - ) - - expect(queryByTestId('execute-through-role-form-btn')).toBeInTheDocument() - }) - - it('should not offer to execute through a role if the user is a safe owner and role member but the role lacks permissions', () => { - jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_TARGET_NOT_ALLOWED]) - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) - isSafeOwner = true - - const { queryByTestId } = render( - , - ) - - expect(queryByTestId('execute-through-role-form-btn')).not.toBeInTheDocument() - }) - - it('should offer to execute through a role if the user is a role member but not a safe owner, even if the role lacks permissions', () => { - jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_TARGET_NOT_ALLOWED]) - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) - isSafeOwner = false - - const { queryByTestId } = render( - , - ) - - expect(queryByTestId('execute-through-role-form-btn')).toBeInTheDocument() - }) - - it('should not offer to execute through a role if the transaction can also be directly executed without going through the role', () => { - jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_OK]) - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - - const { queryByTestId } = render( - , - ) - - expect(queryByTestId('execute-through-role-form-btn')).not.toBeInTheDocument() - }) - }) - - it('should not display radio options if execution is the only option', () => { - jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([]) + it('should display a loading component', () => { + const { container } = render() - const { queryByText } = render( - , - ) - expect(queryByText('Would you like to execute the transaction immediately?')).not.toBeInTheDocument() + expect(container).toMatchSnapshot() }) - it('should display a sign/execute title if that option is selected', () => { - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - - const { getByTestId, getByText } = render( - , + it('should display a confirmation screen', async () => { + jest.spyOn(hooks, 'useProposeTx').mockReturnValue([ + { + txInfo: {}, + } as TransactionDetails, + undefined, + false, + ]) + + const { container, getByTestId } = render( + + + , ) - expect(getByText('Would you like to execute the transaction immediately?')).toBeInTheDocument() - - const executeCheckbox = getByTestId('execute-checkbox') - const signCheckbox = getByTestId('sign-checkbox') - - expect(getByText("You're about to execute this transaction.")).toBeInTheDocument() - - fireEvent.click(signCheckbox) - - expect(getByText("You're about to confirm this transaction.")).toBeInTheDocument() - - fireEvent.click(executeCheckbox) - - expect(getByText("You're about to execute this transaction.")).toBeInTheDocument() + expect(getByTestId('sign-btn')).toBeInTheDocument() + expect(container).toMatchSnapshot() }) - it('should not display safeTxError message for valid transactions', () => { - const { queryByText } = render( - , + it('should display an error screen', async () => { + jest.spyOn(hooks, 'useProposeTx').mockReturnValue([undefined, new Error('This is a mock error message'), false]) + + const { container } = render( + + + , ) - expect( - queryByText('This transaction will most likely fail. To save gas costs, avoid confirming the transaction.'), - ).not.toBeInTheDocument() + expect(container.querySelector('sign-btn')).not.toBeInTheDocument() + expect(container).toMatchSnapshot() }) }) - -const ROLES_MOD_ADDRESS = '0x1234567890000000000000000000000000000000' -const ROLE_KEY = encodeBytes32String('eth_wrapping') - -const TEST_ROLE_OK: execThroughRoleHooks.Role = { - modAddress: ROLES_MOD_ADDRESS, - roleKey: ROLE_KEY as `0x${string}`, - multiSend: '0x9641d764fc13c8b624c04430c7356c1c7c8102e2', - status: Status.Ok, -} - -const TEST_ROLE_TARGET_NOT_ALLOWED: execThroughRoleHooks.Role = { - modAddress: ROLES_MOD_ADDRESS, - roleKey: ROLE_KEY as `0x${string}`, - multiSend: '0x9641d764fc13c8b624c04430c7356c1c7c8102e2', - status: Status.TargetAddressNotAllowed, -} diff --git a/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteForm.test.tsx b/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteForm.test.tsx new file mode 100644 index 0000000000..2abab2eef7 --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteForm.test.tsx @@ -0,0 +1,365 @@ +import { SignOrExecuteForm } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' +import * as hooks from '@/components/tx/SignOrExecuteForm/hooks' +import * as execThroughRoleHooks from '@/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks' +import { safeTxBuilder } from '@/tests/builders/safeTx' +import { render } from '@/tests/test-utils' +import { fireEvent } from '@testing-library/react' +import { encodeBytes32String } from 'ethers' +import { Status } from 'zodiac-roles-deployments' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' + +let isSafeOwner = true +// mock useIsSafeOwner +jest.mock('@/hooks/useIsSafeOwner', () => ({ + __esModule: true, + default: jest.fn(() => isSafeOwner), +})) + +jest.mock('@/hooks/useSafeAddress', () => ({ + __esModule: true, + default: jest.fn(() => '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67'), +})) + +const txDetails = { + safeAddress: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', + txId: 'multisig_0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67_0x938635afdeab5ab17b377896f10dbe161fcc44d488296bc0000b733623d57c80', + executedAt: null, + txStatus: 'AWAITING_EXECUTION', + txInfo: { + type: 'SettingsChange', + humanDescription: 'Add new owner 0xd8dA...6045 with threshold 1', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + valueDecoded: null, + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + valueDecoded: null, + }, + ], + }, + settingsInfo: { + type: 'ADD_OWNER', + owner: { + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + name: null, + logoUri: null, + }, + threshold: 1, + }, + }, + txData: { + hexData: + '0x0d582f13000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000000000000000001', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + valueDecoded: null, + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + valueDecoded: null, + }, + ], + }, + to: { + value: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', + name: 'SafeProxy', + logoUri: null, + }, + value: '0', + operation: 0, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + txHash: null, + detailedExecutionInfo: { + type: 'MULTISIG', + submittedAt: 1726497729356, + nonce: 8, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + name: 'MetaMultiSigWallet', + logoUri: null, + }, + safeTxHash: '0x938635afdeab5ab17b377896f10dbe161fcc44d488296bc0000b733623d57c80', + executor: null, + signers: [ + { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: null, + logoUri: null, + }, + ], + confirmationsRequired: 1, + confirmations: [ + { + signer: { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: null, + logoUri: null, + }, + signature: + '0xd91721922d38384a4d40b20d923c49cefb56f60bfe0b357de11a4a044483d670075842d7bba26cf4aa84788ab0bd85137ad09c7f9cd84154db00d456b15e42dc1b', + submittedAt: 1726497740521, + }, + ], + rejectors: [], + gasTokenInfo: null, + trusted: true, + proposer: { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: null, + logoUri: null, + }, + }, + safeAppInfo: null, +} as unknown as TransactionDetails + +describe('SignOrExecute', () => { + beforeEach(() => { + isSafeOwner = true + }) + + it('should display a safeTxError', () => { + const { getByText } = render( + , + ) + + expect( + getByText('This transaction will most likely fail. To save gas costs, avoid confirming the transaction.'), + ).toBeInTheDocument() + }) + + describe('Existing transaction', () => { + it('should display radio options to sign or execute if both are possible', () => { + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + + const { getByText } = render( + , + ) + + expect(getByText('Would you like to execute the transaction immediately?')).toBeInTheDocument() + }) + }) + + describe('New transaction', () => { + it('should display radio options to sign or execute if both are possible', () => { + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(true) + + const { getByText } = render( + , + ) + + expect(getByText('Would you like to execute the transaction immediately?')).toBeInTheDocument() + }) + + it('should offer to execute through a role if the user is a role member and the transaction is executable through the role', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_OK]) + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) + + const { queryByTestId } = render( + , + ) + + expect(queryByTestId('execute-through-role-form-btn')).toBeInTheDocument() + }) + + it('should not offer to execute through a role if the user is a safe owner and role member but the role lacks permissions', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_TARGET_NOT_ALLOWED]) + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) + isSafeOwner = true + + const { queryByTestId } = render( + , + ) + + expect(queryByTestId('execute-through-role-form-btn')).not.toBeInTheDocument() + }) + + it('should offer to execute through a role if the user is a role member but not a safe owner, even if the role lacks permissions', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_TARGET_NOT_ALLOWED]) + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) + isSafeOwner = false + + const { queryByTestId } = render( + , + ) + + expect(queryByTestId('execute-through-role-form-btn')).toBeInTheDocument() + }) + + it('should not offer to execute through a role if the transaction can also be directly executed without going through the role', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_OK]) + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + + const { queryByTestId } = render( + , + ) + + expect(queryByTestId('execute-through-role-form-btn')).not.toBeInTheDocument() + }) + }) + + it('should not display radio options if execution is the only option', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([]) + + const { queryByText } = render( + , + ) + expect(queryByText('Would you like to execute the transaction immediately?')).not.toBeInTheDocument() + }) + + it('should display a sign/execute title if that option is selected', () => { + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + + const { getByTestId, getByText } = render( + , + ) + + expect(getByText('Would you like to execute the transaction immediately?')).toBeInTheDocument() + + const executeCheckbox = getByTestId('execute-checkbox') + const signCheckbox = getByTestId('sign-checkbox') + + expect(getByText("You're about to execute this transaction.")).toBeInTheDocument() + + fireEvent.click(signCheckbox) + + expect(getByText("You're about to confirm this transaction.")).toBeInTheDocument() + + fireEvent.click(executeCheckbox) + + expect(getByText("You're about to execute this transaction.")).toBeInTheDocument() + }) + + it('should not display safeTxError message for valid transactions', () => { + const { queryByText } = render( + , + ) + + expect( + queryByText('This transaction will most likely fail. To save gas costs, avoid confirming the transaction.'), + ).not.toBeInTheDocument() + }) +}) + +const ROLES_MOD_ADDRESS = '0x1234567890000000000000000000000000000000' +const ROLE_KEY = encodeBytes32String('eth_wrapping') + +const TEST_ROLE_OK: execThroughRoleHooks.Role = { + modAddress: ROLES_MOD_ADDRESS, + roleKey: ROLE_KEY as `0x${string}`, + multiSend: '0x9641d764fc13c8b624c04430c7356c1c7c8102e2', + status: Status.Ok, +} + +const TEST_ROLE_TARGET_NOT_ALLOWED: execThroughRoleHooks.Role = { + modAddress: ROLES_MOD_ADDRESS, + roleKey: ROLE_KEY as `0x${string}`, + multiSend: '0x9641d764fc13c8b624c04430c7356c1c7c8102e2', + status: Status.TargetAddressNotAllowed, +} diff --git a/src/components/tx/SignOrExecuteForm/__tests__/__snapshots__/SignOrExecute.test.tsx.snap b/src/components/tx/SignOrExecuteForm/__tests__/__snapshots__/SignOrExecute.test.tsx.snap new file mode 100644 index 0000000000..87e0722b7b --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/__tests__/__snapshots__/SignOrExecute.test.tsx.snap @@ -0,0 +1,524 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SignOrExecute should display a confirmation screen 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ confirm +
+

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

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

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

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

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

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

    + Created: +

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

+ Any transaction will require the confirmation of: +

+

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

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

+

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

+

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

+

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

+ Any transaction requires the confirmation of: +

+

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

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

+

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

+ Any transaction requires the confirmation of: +

+

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

+
+
+
+
+
+ +
+
+
+`; + +exports[`ConfirmationView should display a confirmation with method call when the transaction type is not found in the ConfirmationView's mapper 1`] = ` +
+
+
+
+ +
+
+
+`; diff --git a/src/components/tx/confirmation-views/index.tsx b/src/components/tx/confirmation-views/index.tsx new file mode 100644 index 0000000000..4d0df98b55 --- /dev/null +++ b/src/components/tx/confirmation-views/index.tsx @@ -0,0 +1,85 @@ +import { type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import DecodedTx from '../DecodedTx' +import ConfirmationOrder from '../ConfirmationOrder' +import useDecodeTx from '@/hooks/useDecodeTx' +import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { isCustomTxInfo, isGenericConfirmation } from '@/utils/transaction-guards' +import { type ReactNode, useContext, useMemo } from 'react' +import TxData from '@/components/transactions/TxDetails/TxData' +import type { NarrowConfirmationViewProps } from './types' +import SettingsChange from './SettingsChange' +import ChangeThreshold from './ChangeThreshold' +import BatchTransactions from './BatchTransactions' +import { TxModalContext } from '@/components/tx-flow' +import { isSettingsChangeView, isChangeThresholdView, isConfirmBatchView } from './utils' + +type ConfirmationViewProps = { + txDetails?: TransactionDetails + safeTx?: SafeTransaction + txId?: string + isBatch?: boolean + isApproval?: boolean + isCreation?: boolean + showMethodCall?: boolean // @TODO: remove this prop when we migrate all tx types + children?: ReactNode +} + +const getConfirmationViewComponent = ({ + txDetails, + txInfo, + txFlow, +}: NarrowConfirmationViewProps & { txFlow?: JSX.Element }) => { + if (isChangeThresholdView(txInfo)) return + + if (isConfirmBatchView(txFlow)) return + + if (isSettingsChangeView(txInfo)) return + + return null +} + +const ConfirmationView = (props: ConfirmationViewProps) => { + const { txId } = props.txDetails || {} + const [decodedData] = useDecodeTx(props.safeTx) + const { txFlow } = useContext(TxModalContext) + + const ConfirmationViewComponent = useMemo( + () => + props.txDetails + ? getConfirmationViewComponent({ + txDetails: props.txDetails, + txInfo: props.txDetails.txInfo, + txFlow, + }) + : undefined, + [props.txDetails, txFlow], + ) + const showTxDetails = txId && !props.isCreation && props.txDetails && !isCustomTxInfo(props.txDetails.txInfo) + + return ( + <> + {ConfirmationViewComponent || + (showTxDetails && props.txDetails && )} + + {decodedData && } + + {props.children} + + + + ) +} + +export default ConfirmationView diff --git a/src/components/tx/confirmation-views/types.d.ts b/src/components/tx/confirmation-views/types.d.ts new file mode 100644 index 0000000000..6e60f8fb9c --- /dev/null +++ b/src/components/tx/confirmation-views/types.d.ts @@ -0,0 +1,6 @@ +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' + +export type NarrowConfirmationViewProps = { + txDetails: TransactionDetails + txInfo: TransactionDetails['txInfo'] +} diff --git a/src/components/tx/confirmation-views/utils.ts b/src/components/tx/confirmation-views/utils.ts new file mode 100644 index 0000000000..5329b31aa2 --- /dev/null +++ b/src/components/tx/confirmation-views/utils.ts @@ -0,0 +1,10 @@ +import type { TransactionInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { SettingsInfoType, TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import { ConfirmBatchFlow } from '@/components/tx-flow/flows' + +export const isSettingsChangeView = (txInfo: TransactionInfo) => txInfo.type === TransactionInfoType.SETTINGS_CHANGE + +export const isConfirmBatchView = (txFlow?: JSX.Element) => txFlow?.type === ConfirmBatchFlow + +export const isChangeThresholdView = (txInfo: TransactionInfo) => + txInfo.type === TransactionInfoType.SETTINGS_CHANGE && txInfo.settingsInfo?.type === SettingsInfoType.CHANGE_THRESHOLD diff --git a/src/features/counterfactual/CounterfactualForm.tsx b/src/features/counterfactual/CounterfactualForm.tsx index 98d59a5712..e91a6199cf 100644 --- a/src/features/counterfactual/CounterfactualForm.tsx +++ b/src/features/counterfactual/CounterfactualForm.tsx @@ -18,7 +18,7 @@ import { useCurrentChain } from '@/hooks/useChains' import { getTxOptions } from '@/utils/transactions' import CheckWallet from '@/components/common/CheckWallet' import { useIsExecutionLoop } from '@/components/tx/SignOrExecuteForm/hooks' -import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm' +import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import AdvancedParams, { useAdvancedParams } from '@/components/tx/AdvancedParams' import { asError } from '@/services/exceptions/utils' @@ -43,6 +43,7 @@ export const CounterfactualForm = ({ isExecutionLoop: ReturnType txSecurity: ReturnType safeTx?: SafeTransaction + isCreation?: boolean }): ReactElement => { const wallet = useWallet() const chain = useCurrentChain() diff --git a/src/tests/transactions.ts b/src/tests/transactions.ts index 244e64e366..3cfdfea9db 100644 --- a/src/tests/transactions.ts +++ b/src/tests/transactions.ts @@ -5,6 +5,8 @@ import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { ERC20__factory, ERC721__factory, Multi_send__factory } from '@/types/contracts' import EthSafeTransaction from '@safe-global/protocol-kit/dist/src/utils/transactions/SafeTransaction' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { TransactionStatus } from '@safe-global/safe-apps-sdk' export const getMockErc20TransferCalldata = (to: string) => { const erc20Interface = ERC20__factory.createInterface() @@ -65,6 +67,23 @@ export const getMockMultiSendCalldata = (recipients: Array): string => { return multiSendInterface.encodeFunctionData('multiSend', [concat(internalTransactions)]) } +export const createMockTransactionDetails = ({ + txInfo, + txData, + detailedExecutionInfo, +}: { + txInfo: TransactionDetails['txInfo'] + txData: TransactionDetails['txData'] + detailedExecutionInfo: TransactionDetails['detailedExecutionInfo'] +}): TransactionDetails => ({ + safeAddress: 'sep:0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', + txId: 'multisig_0xBd69b0a9DC90eB6F9bAc3E4a5875f437348b6415_0xcb83bc36cf4a2998e7fe222e36c458c59c3778f65b4e5bb361c29a73c2de62cc', + txStatus: TransactionStatus.AWAITING_CONFIRMATIONS, + txInfo, + txData, + detailedExecutionInfo, +}) + // TODO: Replace with safeTxBuilder export const createMockSafeTransaction = ({ to,