- All actions
+
+ {title}
}>
}>
+
+
+
+ {safeTx ? (
+
+ ) : error ? (
+
+ This Safe App initiated a transaction which cannot be processed. Please get in touch with the developer of
+ this Safe App for more information.
+
+ ) : null}
+
+ )
+}
+
+export default ReviewSafeAppsTx
diff --git a/src/components/tx-flow/flows/SafeAppsTx/index.tsx b/src/components/tx-flow/flows/SafeAppsTx/index.tsx
new file mode 100644
index 0000000000..fa778d01aa
--- /dev/null
+++ b/src/components/tx-flow/flows/SafeAppsTx/index.tsx
@@ -0,0 +1,27 @@
+import type { BaseTransaction, RequestId, SendTransactionRequestParams } from '@safe-global/safe-apps-sdk'
+import TxLayout from '@/components/tx-flow/common/TxLayout'
+import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import ReviewSafeAppsTx from './ReviewSafeAppsTx'
+import { AppTitle } from '@/components/tx-flow/flows/SignMessage'
+
+export type SafeAppsTxParams = {
+ appId?: string
+ app?: SafeAppData
+ requestId: RequestId
+ txs: BaseTransaction[]
+ params?: SendTransactionRequestParams
+}
+
+const SafeAppsTxFlow = ({ data }: { data: SafeAppsTxParams }) => {
+ return (
+
}
+ step={0}
+ >
+
+
+ )
+}
+
+export default SafeAppsTxFlow
diff --git a/src/components/safe-messages/MsgModal/index.test.tsx b/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx
similarity index 89%
rename from src/components/safe-messages/MsgModal/index.test.tsx
rename to src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx
index d44c40b44e..e11fb881c2 100644
--- a/src/components/safe-messages/MsgModal/index.test.tsx
+++ b/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx
@@ -2,7 +2,7 @@ import { hexlify, hexZeroPad, toUtf8Bytes } from 'ethers/lib/utils'
import type { ChainInfo, SafeInfo, SafeMessage } from '@safe-global/safe-gateway-typescript-sdk'
import { SafeMessageListItemType } from '@safe-global/safe-gateway-typescript-sdk'
-import MsgModal from '@/components/safe-messages/MsgModal'
+import SignMessage from './SignMessage'
import * as useIsWrongChainHook from '@/hooks/useIsWrongChain'
import * as useIsSafeOwnerHook from '@/hooks/useIsSafeOwner'
import * as useWalletHook from '@/hooks/wallets/useWallet'
@@ -67,7 +67,7 @@ const mockOnboard = {
},
} as unknown as OnboardAPI
-describe('MsgModal', () => {
+describe('SignMessage', () => {
beforeEach(() => {
jest.clearAllMocks()
@@ -94,12 +94,11 @@ describe('MsgModal', () => {
it('renders the (decoded) message', () => {
const { getByText } = render(
-
,
)
@@ -108,13 +107,7 @@ describe('MsgModal', () => {
it('displays the SafeMessage message', () => {
const { getByText } = render(
-
,
+
,
)
expect(getByText('0xaa05af77f274774b8bdc7b61d98bc40da523dc2821fdea555f4d6aa413199bcc')).toBeInTheDocument()
@@ -122,13 +115,7 @@ describe('MsgModal', () => {
it('generates the SafeMessage hash if not provided', () => {
const { getByText } = render(
-
,
+
,
)
expect(getByText('0x73d0948ac608c5d00a6dd26dd396cce79b459307ea365f5a5bd5d3119c2d9708')).toBeInTheDocument()
@@ -176,13 +163,7 @@ describe('MsgModal', () => {
it('renders the message', () => {
const { getByText } = render(
-
,
+
,
)
Object.keys(EXAMPLE_MESSAGE.message).forEach((key) => {
@@ -194,13 +175,7 @@ describe('MsgModal', () => {
it('displays the SafeMessage message', () => {
const { getByText } = render(
-
,
+
,
)
expect(getByText('0xd5ffe9f6faa9cc9294673fb161b1c7b3e0c98241e90a38fc6c451941f577fb19')).toBeInTheDocument()
@@ -208,13 +183,7 @@ describe('MsgModal', () => {
it('generates the SafeMessage hash if not provided', () => {
const { getByText } = render(
-
,
+
,
)
expect(getByText('0x10c926c4f417e445de3fddc7ad8c864f81b9c81881b88eba646015de10d21613')).toBeInTheDocument()
@@ -228,12 +197,11 @@ describe('MsgModal', () => {
jest.spyOn(useAsyncHook, 'default').mockReturnValue([undefined, new Error('SafeMessage not found'), false])
const { getByText } = render(
-
,
)
@@ -300,13 +268,7 @@ describe('MsgModal', () => {
jest.spyOn(useSafeMessages, 'useSafeMessage').mockReturnValue(msg)
const { getByText } = render(
-
,
+
,
)
await act(async () => {
@@ -343,12 +305,11 @@ describe('MsgModal', () => {
jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => false)
const { getByText } = render(
-
,
)
@@ -365,12 +326,11 @@ describe('MsgModal', () => {
jest.spyOn(useChainsHook, 'useCurrentChain').mockReturnValue({ chainName: 'Goerli' } as ChainInfo)
const { getByText } = render(
-
,
)
@@ -391,12 +351,11 @@ describe('MsgModal', () => {
jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => false)
const { getByText } = render(
-
,
)
@@ -445,13 +404,7 @@ describe('MsgModal', () => {
jest.spyOn(useSafeMessages, 'useSafeMessage').mockReturnValue(msg)
const { getByText } = render(
-
,
+
,
)
await waitFor(() => {
@@ -479,12 +432,11 @@ describe('MsgModal', () => {
.mockImplementation(() => Promise.reject(new Error('Test error')))
const { getByText } = render(
-
,
)
@@ -521,12 +473,11 @@ describe('MsgModal', () => {
.mockImplementation(() => Promise.reject(new Error('Test error')))
const { getByText } = render(
-
,
)
diff --git a/src/components/safe-messages/MsgModal/index.tsx b/src/components/tx-flow/flows/SignMessage/SignMessage.tsx
similarity index 54%
rename from src/components/safe-messages/MsgModal/index.tsx
rename to src/components/tx-flow/flows/SignMessage/SignMessage.tsx
index ab9ab83aef..9e9f7b788b 100644
--- a/src/components/safe-messages/MsgModal/index.tsx
+++ b/src/components/tx-flow/flows/SignMessage/SignMessage.tsx
@@ -1,13 +1,10 @@
-import { Grid, DialogActions, Button, Box, Typography, DialogContent, SvgIcon } from '@mui/material'
+import { Grid, Button, Box, Typography, SvgIcon, CardContent, CardActions } from '@mui/material'
import { useTheme } from '@mui/material/styles'
-import { useCallback, useState } from 'react'
+import { useContext } from 'react'
import { SafeMessageListItemType, SafeMessageStatus } from '@safe-global/safe-gateway-typescript-sdk'
import type { ReactElement } from 'react'
import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk'
import type { RequestId } from '@safe-global/safe-apps-sdk'
-
-import ModalDialog, { ModalDialogTitle } from '@/components/common/ModalDialog'
-import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard'
import EthHashInfo from '@/components/common/EthHashInfo'
import RequiredIcon from '@/public/images/messages/required.svg'
import useSafeInfo from '@/hooks/useSafeInfo'
@@ -17,21 +14,17 @@ import ErrorMessage from '@/components/tx/ErrorMessage'
import useWallet from '@/hooks/wallets/useWallet'
import { useSafeMessage } from '@/hooks/messages/useSafeMessages'
import useOnboard, { switchWallet } from '@/hooks/wallets/useOnboard'
-
-import txStepperCss from '@/components/tx/TxStepper/styles.module.css'
-import { DecodedMsg } from '../DecodedMsg'
+import { TxModalContext } from '@/components/tx-flow'
import CopyButton from '@/components/common/CopyButton'
import { WrongChainWarning } from '@/components/tx/WrongChainWarning'
import MsgSigners from '@/components/safe-messages/MsgSigners'
-import { ConfirmationDialog } from './ConfirmationDialog'
import useDecodedSafeMessage from '@/hooks/messages/useDecodedSafeMessage'
import useSyncSafeMessageSigner from '@/hooks/messages/useSyncSafeMessageSigner'
import SuccessMessage from '@/components/tx/SuccessMessage'
-import InfoBox from '../InfoBox'
import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab'
-
-const APP_LOGO_FALLBACK_IMAGE = '/images/apps/apps-icon.svg'
-const APP_NAME_FALLBACK = 'Sign message off-chain'
+import InfoBox from '@/components/safe-messages/InfoBox'
+import { DecodedMsg } from '@/components/safe-messages/DecodedMsg'
+import TxCard from '@/components/tx-flow/common/TxCard'
const createSkeletonMessage = (confirmationsRequired: number): SafeMessage => {
return {
@@ -65,7 +58,7 @@ const MessageHashField = ({ label, hashValue }: { label: string; hashValue: stri
const DialogHeader = ({ threshold }: { threshold: number }) => (
<>
-
+
@@ -77,33 +70,6 @@ const DialogHeader = ({ threshold }: { threshold: number }) => (
>
)
-const DialogTitle = ({
- onClose,
- name,
- logoUri,
-}: {
- onClose: () => void
- name: string | null
- logoUri: string | null
-}) => {
- const appName = name || APP_NAME_FALLBACK
- const appLogo = logoUri || APP_LOGO_FALLBACK_IMAGE
- return (
-
-
-
-
-
-
- {appName}
-
-
-
-
-
- )
-}
-
const MessageDialogError = ({ isOwner, submitError }: { isOwner: boolean; submitError: Error | undefined }) => {
const wallet = useWallet()
const onboard = useOnboard()
@@ -136,8 +102,8 @@ const AlreadySignedByOwnerMessage = ({ hasSigned }: { hasSigned: boolean }) => {
}
return (
-
-
+
+
Your connected wallet has already signed this message.
@@ -150,32 +116,23 @@ const AlreadySignedByOwnerMessage = ({ hasSigned }: { hasSigned: boolean }) => {
)
}
-type BaseProps = {
- onClose: () => void
-} & Pick
+type BaseProps = Pick
// Custom Safe Apps do not have a `safeAppId`
-type ProposeProps = BaseProps & {
+export type ProposeProps = BaseProps & {
safeAppId?: number
requestId: RequestId
}
// A proposed message does not return the `safeAppId` but the `logoUri` and `name` of the Safe App that proposed it
-type ConfirmProps = BaseProps & {
+export type ConfirmProps = BaseProps & {
safeAppId?: never
requestId?: RequestId
}
-const MsgModal = ({
- onClose,
- logoUri,
- name,
- message,
- safeAppId,
- requestId,
-}: ProposeProps | ConfirmProps): ReactElement => {
+const SignMessage = ({ message, safeAppId, requestId }: ProposeProps | ConfirmProps): ReactElement => {
// Hooks & variables
- const [showCloseTooltip, setShowCloseTooltip] = useState(false)
+ const { setTxFlow } = useContext(TxModalContext)
const { palette } = useTheme()
const { safe } = useSafeInfo()
const isOwner = useIsSafeOwner()
@@ -198,68 +155,58 @@ const MsgModal = ({
safeMessageHash,
requestId,
safeAppId,
- onClose,
+ () => setTxFlow(undefined),
)
- const handleClose = useCallback(() => {
- if (requestId && (!ongoingMessage || ongoingMessage.status === SafeMessageStatus.NEEDS_CONFIRMATION)) {
- // If we are in a Safe app modal we want to keep the modal open
- setShowCloseTooltip(true)
- } else {
- onClose()
- }
- }, [onClose, ongoingMessage, requestId])
-
return (
<>
-
-
-
-
-
-
-
-
- Message:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Cancel
-
- Sign
-
-
-
-
- setShowCloseTooltip(false)} onClose={onClose} />
+
+
+
+
+
+ Message:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sign
+
+
+
>
)
}
-export default MsgModal
+export default SignMessage
diff --git a/src/components/tx-flow/flows/SignMessage/index.tsx b/src/components/tx-flow/flows/SignMessage/index.tsx
new file mode 100644
index 0000000000..d8d4de3a42
--- /dev/null
+++ b/src/components/tx-flow/flows/SignMessage/index.tsx
@@ -0,0 +1,35 @@
+import TxLayout from '@/components/tx-flow/common/TxLayout'
+import SignMessage, { type ConfirmProps, type ProposeProps } from '@/components/tx-flow/flows/SignMessage/SignMessage'
+import { Box, Typography } from '@mui/material'
+import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard'
+
+const APP_LOGO_FALLBACK_IMAGE = '/images/apps/apps-icon.svg'
+const APP_NAME_FALLBACK = 'Sign message off-chain'
+
+export const AppTitle = ({ name, logoUri }: { name?: string | null; logoUri?: string | null }) => {
+ const appName = name || APP_NAME_FALLBACK
+ const appLogo = logoUri || APP_LOGO_FALLBACK_IMAGE
+ return (
+
+
+
+ {appName}
+
+
+ )
+}
+
+const SignMessageFlow = ({ ...props }: ProposeProps | ConfirmProps) => {
+ return (
+ }
+ step={0}
+ hideNonce
+ >
+
+
+ )
+}
+
+export default SignMessageFlow
diff --git a/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx
new file mode 100644
index 0000000000..83d265efc9
--- /dev/null
+++ b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx
@@ -0,0 +1,78 @@
+import { Methods } from '@safe-global/safe-apps-sdk'
+import * as web3 from '@/hooks/wallets/web3'
+import { Web3Provider } from '@ethersproject/providers'
+import { render, screen } from '@/tests/test-utils'
+import { SafeAppAccessPolicyTypes } from '@safe-global/safe-gateway-typescript-sdk'
+import ReviewSignMessageOnChain from '@/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain'
+
+describe('ReviewSignMessageOnChain', () => {
+ test('can handle messages with EIP712Domain type in the JSON-RPC payload', () => {
+ jest.spyOn(web3, '_getWeb3').mockImplementation(() => new Web3Provider(jest.fn()))
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Interact with SignMessageLib')).toBeInTheDocument()
+ })
+})
diff --git a/src/components/safe-apps/SafeAppsSignMessageModal/ReviewSafeAppsSignMessage.tsx b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx
similarity index 61%
rename from src/components/safe-apps/SafeAppsSignMessageModal/ReviewSafeAppsSignMessage.tsx
rename to src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx
index a22f775f6d..0d94b49352 100644
--- a/src/components/safe-apps/SafeAppsSignMessageModal/ReviewSafeAppsSignMessage.tsx
+++ b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx
@@ -1,12 +1,11 @@
import type { ReactElement } from 'react'
-import { useState } from 'react'
+import { useContext, useEffect } from 'react'
import { useMemo } from 'react'
import { hashMessage, _TypedDataEncoder } from 'ethers/lib/utils'
import { Box } from '@mui/system'
import { Typography, SvgIcon } from '@mui/material'
import WarningIcon from '@/public/images/notifications/warning.svg'
-import { isObjectEIP712TypedData, Methods } from '@safe-global/safe-apps-sdk'
-import type { SafeTransaction } from '@safe-global/safe-core-sdk-types'
+import { type EIP712TypedData, isObjectEIP712TypedData, Methods, type RequestId } from '@safe-global/safe-apps-sdk'
import { OperationType } from '@safe-global/safe-core-sdk-types'
import SendFromBlock from '@/components/tx/SendFromBlock'
@@ -14,9 +13,7 @@ import { InfoDetails } from '@/components/transactions/InfoDetails'
import EthHashInfo from '@/components/common/EthHashInfo'
import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm'
import { generateDataRowValue } from '@/components/transactions/TxDetails/Summary/TxDataRow'
-import type { SafeAppsSignMessageParams } from '@/components/safe-apps/SafeAppsSignMessageModal'
import useChainId from '@/hooks/useChainId'
-import useAsync from '@/hooks/useAsync'
import { getReadOnlySignMessageLibContract } from '@/services/contracts/safeContracts'
import { DecodedMsg } from '@/components/safe-messages/DecodedMsg'
import CopyButton from '@/components/common/CopyButton'
@@ -25,18 +22,23 @@ import { createTx, dispatchSafeAppsTx } from '@/services/tx/tx-sender'
import useOnboard from '@/hooks/wallets/useOnboard'
import useSafeInfo from '@/hooks/useSafeInfo'
import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab'
-
-type ReviewSafeAppsSignMessageProps = {
- safeAppsSignMessage: SafeAppsSignMessageParams
+import { type SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
+import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider'
+import { asError } from '@/services/exceptions/utils'
+
+export type SignMessageOnChainProps = {
+ appId?: number
+ app?: SafeAppData
+ requestId: RequestId
+ message: string | EIP712TypedData
+ method: Methods.signMessage | Methods.signTypedMessage
}
-const ReviewSafeAppsSignMessage = ({
- safeAppsSignMessage: { message, method, requestId },
-}: ReviewSafeAppsSignMessageProps): ReactElement => {
+const ReviewSignMessageOnChain = ({ message, method, requestId }: SignMessageOnChainProps): ReactElement => {
const chainId = useChainId()
const { safe } = useSafeInfo()
const onboard = useOnboard()
- const [submitError, setSubmitError] = useState()
+ const { safeTx, setSafeTx, setSafeTxError } = useContext(SafeTxContext)
useHighlightHiddenTab()
@@ -56,7 +58,7 @@ const ReviewSafeAppsSignMessage = ({
return []
}, [isTextMessage, isTypedMessage, message])
- const [safeTx, safeTxError] = useAsync(() => {
+ useEffect(() => {
let txData
if (isTextMessage) {
@@ -74,60 +76,66 @@ const ReviewSafeAppsSignMessage = ({
])
}
- return createTx({
+ const params = {
to: signMessageAddress,
value: '0',
data: txData || '0x',
operation: OperationType.DelegateCall,
- })
- }, [message])
+ }
+ createTx(params).then(setSafeTx).catch(setSafeTxError)
+ }, [
+ isTextMessage,
+ isTypedMessage,
+ message,
+ readOnlySignMessageLibContract,
+ setSafeTx,
+ setSafeTxError,
+ signMessageAddress,
+ ])
const handleSubmit = async () => {
- setSubmitError(undefined)
if (!safeTx || !onboard) return
try {
await dispatchSafeAppsTx(safeTx, requestId, onboard, safe.chainId)
} catch (error) {
- setSubmitError(error as Error)
+ setSafeTxError(asError(error))
}
}
return (
-
- <>
-
-
-
-
-
-
- {safeTx && (
-
-
- Data (hex encoded)
-
- {generateDataRowValue(safeTx.data.data, 'rawData')}
-
- )}
-
-
- Signing method: {method}
-
+
+
-
- Signing message: {readableMessage && }
-
-
+
+
+
-
-
-
- Signing a message with your Safe Account requires a transaction on the blockchain
+ {safeTx && (
+
+
+ Data (hex encoded)
+ {generateDataRowValue(safeTx.data.data, 'rawData')}
- >
+ )}
+
+
+ Signing method: {method}
+
+
+
+ Signing message: {readableMessage && }
+
+
+
+
+
+
+ Signing a message with your Safe Account requires a transaction on the blockchain
+
+
)
}
-export default ReviewSafeAppsSignMessage
+export default ReviewSignMessageOnChain
diff --git a/src/components/tx-flow/flows/SignMessageOnChain/index.tsx b/src/components/tx-flow/flows/SignMessageOnChain/index.tsx
new file mode 100644
index 0000000000..bcfc3c7672
--- /dev/null
+++ b/src/components/tx-flow/flows/SignMessageOnChain/index.tsx
@@ -0,0 +1,19 @@
+import TxLayout from '@/components/tx-flow/common/TxLayout'
+import { AppTitle } from '@/components/tx-flow/flows/SignMessage'
+import ReviewSignMessageOnChain, {
+ type SignMessageOnChainProps,
+} from '@/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain'
+
+const SignMessageOnChainFlow = ({ props }: { props: SignMessageOnChainProps }) => {
+ return (
+ }
+ step={0}
+ >
+
+
+ )
+}
+
+export default SignMessageOnChainFlow
diff --git a/src/components/tx-flow/flows/SuccessScreen/StatusMessage.tsx b/src/components/tx-flow/flows/SuccessScreen/StatusMessage.tsx
new file mode 100644
index 0000000000..572e5ee48a
--- /dev/null
+++ b/src/components/tx-flow/flows/SuccessScreen/StatusMessage.tsx
@@ -0,0 +1,51 @@
+import classNames from 'classnames'
+import { Box, Typography } from '@mui/material'
+import LoadingSpinner, { SpinnerStatus } from '@/components/new-safe/create/steps/StatusStep/LoadingSpinner'
+import { PendingStatus } from '@/store/pendingTxsSlice'
+import css from './styles.module.css'
+
+const getStep = (status: PendingStatus, error?: Error) => {
+ switch (status) {
+ case PendingStatus.PROCESSING:
+ case PendingStatus.RELAYING:
+ return {
+ description: 'Transaction is now processing.',
+ instruction: 'The transaction was confirmed and is now being processed.',
+ }
+ case PendingStatus.INDEXING:
+ return {
+ description: 'Transaction was processed.',
+ instruction: 'It is now being indexed.',
+ }
+ default:
+ return {
+ description: error ? 'Transaction failed' : 'Transaction was successful.',
+ instruction: error ? error.message : '',
+ }
+ }
+}
+
+const StatusMessage = ({ status, error }: { status: PendingStatus; error?: Error }) => {
+ const stepInfo = getStep(status, error)
+
+ const isSuccess = status === undefined
+ const spinnerStatus = error ? SpinnerStatus.ERROR : isSuccess ? SpinnerStatus.SUCCESS : SpinnerStatus.PROCESSING
+
+ return (
+ <>
+
+
+
+ {stepInfo.description}
+
+
+ {stepInfo.instruction && (
+
+ {stepInfo.instruction}
+
+ )}
+ >
+ )
+}
+
+export default StatusMessage
diff --git a/src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx b/src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx
new file mode 100644
index 0000000000..936fe3e6fe
--- /dev/null
+++ b/src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx
@@ -0,0 +1,56 @@
+import { Box, Step, StepConnector, Stepper, Typography } from '@mui/material'
+import css from '@/components/new-safe/create/steps/StatusStep/styles.module.css'
+import EthHashInfo from '@/components/common/EthHashInfo'
+import StatusStep from '@/components/new-safe/create/steps/StatusStep/StatusStep'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import { PendingStatus } from '@/store/pendingTxsSlice'
+
+const StatusStepper = ({ status, txHash }: { status: PendingStatus; txHash?: string }) => {
+ const { safeAddress } = useSafeInfo()
+
+ const isProcessing = status === PendingStatus.PROCESSING || status === PendingStatus.INDEXING || status === undefined
+ const isProcessed = status === PendingStatus.INDEXING || status === undefined
+ const isSuccess = status === undefined
+
+ return (
+ }>
+
+
+
+
+ Your transaction
+
+ {txHash && (
+
+ )}
+
+
+
+
+
+
+
+ {isProcessed ? 'Processed' : 'Processing'}
+
+
+
+
+
+
+
+ {isSuccess ? 'Indexed' : 'Indexing'}
+
+
+
+
+ )
+}
+
+export default StatusStepper
diff --git a/src/components/tx-flow/flows/SuccessScreen/index.tsx b/src/components/tx-flow/flows/SuccessScreen/index.tsx
new file mode 100644
index 0000000000..f3d1fadf5a
--- /dev/null
+++ b/src/components/tx-flow/flows/SuccessScreen/index.tsx
@@ -0,0 +1,83 @@
+import { useRouter } from 'next/router'
+import StatusMessage from './StatusMessage'
+import StatusStepper from './StatusStepper'
+import { AppRoutes } from '@/config/routes'
+import { Button, Container, Divider, Paper } from '@mui/material'
+import classnames from 'classnames'
+import Link from 'next/link'
+import { type UrlObject } from 'url'
+import css from './styles.module.css'
+import { useAppSelector } from '@/store'
+import { selectPendingTxById } from '@/store/pendingTxsSlice'
+import { useEffect, useState } from 'react'
+import { getBlockExplorerLink } from '@/utils/chains'
+import { useCurrentChain } from '@/hooks/useChains'
+import { TxEvent, txSubscribe } from '@/services/tx/txEvents'
+
+export const SuccessScreen = ({ txId }: { txId: string }) => {
+ const [localTxHash, setLocalTxHash] = useState()
+ const [error, setError] = useState()
+ const router = useRouter()
+ const chain = useCurrentChain()
+ const pendingTx = useAppSelector((state) => selectPendingTxById(state, txId))
+ const { txHash = '', status } = pendingTx || {}
+
+ useEffect(() => {
+ if (!txHash) return
+
+ setLocalTxHash(txHash)
+ }, [txHash])
+
+ useEffect(() => {
+ const unsubscribe = txSubscribe(TxEvent.FAILED, (detail) => {
+ if (detail.txId === txId) setError(detail.error)
+ })
+
+ return unsubscribe
+ }, [txId])
+
+ const homeLink: UrlObject = {
+ pathname: AppRoutes.home,
+ query: { safe: router.query.safe },
+ }
+
+ const txLink = chain && localTxHash ? getBlockExplorerLink(chain, localTxHash) : undefined
+
+ return (
+
+
+
+
+
+ {!error && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+
+
+ Back to dashboard
+
+
+ {txLink && (
+
+ View transaction
+
+ )}
+
+
+ )
+}
diff --git a/src/components/tx-flow/flows/SuccessScreen/styles.module.css b/src/components/tx-flow/flows/SuccessScreen/styles.module.css
new file mode 100644
index 0000000000..aa72e2cf90
--- /dev/null
+++ b/src/components/tx-flow/flows/SuccessScreen/styles.module.css
@@ -0,0 +1,35 @@
+.row {
+ width: 100%;
+ padding: var(--space-4) var(--space-7);
+}
+
+@media (max-width: 599.95px) {
+ .row {
+ padding: var(--space-2);
+ }
+}
+
+.buttons {
+ display: flex;
+ justify-content: center;
+ gap: var(--space-2);
+ font-size: 14px;
+}
+
+.instructions {
+ padding: var(--space-3);
+ margin-top: var(--space-4);
+ border-style: solid;
+ border-width: 1px;
+ border-radius: 6px;
+}
+
+.errorBg {
+ background-color: var(--color-error-background);
+ border-color: var(--color-error-light);
+}
+
+.infoBg {
+ background-color: var(--color-info-background);
+ border-color: var(--color-info-light);
+}
diff --git a/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx b/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx
new file mode 100644
index 0000000000..44f2e7a844
--- /dev/null
+++ b/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx
@@ -0,0 +1,195 @@
+import { type ReactElement, useMemo, useState, useCallback, useContext, useEffect } from 'react'
+import { type TokenInfo } from '@safe-global/safe-gateway-typescript-sdk'
+import { useVisibleBalances } from '@/hooks/useVisibleBalances'
+import useAddressBook from '@/hooks/useAddressBook'
+import useChainId from '@/hooks/useChainId'
+import { getSafeTokenAddress } from '@/components/common/SafeTokenWidget'
+import useIsSafeTokenPaused from '@/hooks/useIsSafeTokenPaused'
+import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary'
+import { useAppSelector } from '@/store'
+import { selectSpendingLimits } from '@/store/spendingLimitsSlice'
+import useWallet from '@/hooks/wallets/useWallet'
+import { FormProvider, useForm } from 'react-hook-form'
+import useSpendingLimit from '@/hooks/useSpendingLimit'
+import { BigNumber } from '@ethersproject/bignumber'
+import { sameAddress } from '@/utils/addresses'
+import { Box, Button, CardActions, Divider, FormControl, Grid, SvgIcon, Typography } from '@mui/material'
+import TokenIcon from '@/components/common/TokenIcon'
+import AddressBookInput from '@/components/common/AddressBookInput'
+import AddressInputReadOnly from '@/components/common/AddressInputReadOnly'
+import InfoIcon from '@/public/images/notifications/info.svg'
+import SpendingLimitRow from '@/components/tx/SpendingLimitRow'
+import { TokenTransferFields, type TokenTransferParams, TokenTransferType } from '.'
+import TxCard from '../../common/TxCard'
+import { formatVisualAmount, safeFormatUnits } from '@/utils/formatters'
+import commonCss from '@/components/tx-flow/common/styles.module.css'
+import TokenAmountInput, { TokenAmountFields } from '@/components/common/TokenAmountInput'
+import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider'
+
+export const AutocompleteItem = (item: { tokenInfo: TokenInfo; balance: string }): ReactElement => (
+
+
+
+
+ {item.tokenInfo.name}
+
+
+ {formatVisualAmount(item.balance, item.tokenInfo.decimals)} {item.tokenInfo.symbol}
+
+
+
+)
+
+const CreateTokenTransfer = ({
+ params,
+ onSubmit,
+ txNonce,
+}: {
+ params: TokenTransferParams
+ onSubmit: (data: TokenTransferParams) => void
+ txNonce?: number
+}): ReactElement => {
+ const disableSpendingLimit = txNonce !== undefined
+ const { balances } = useVisibleBalances()
+ const addressBook = useAddressBook()
+ const chainId = useChainId()
+ const safeTokenAddress = getSafeTokenAddress(chainId)
+ const isSafeTokenPaused = useIsSafeTokenPaused()
+ const isOnlySpendingLimitBeneficiary = useIsOnlySpendingLimitBeneficiary()
+ const spendingLimits = useAppSelector(selectSpendingLimits)
+ const wallet = useWallet()
+ const { setNonce } = useContext(SafeTxContext)
+ const [recipientFocus, setRecipientFocus] = useState(!params.recipient)
+
+ useEffect(() => {
+ if (txNonce) {
+ setNonce(txNonce)
+ }
+ }, [setNonce, txNonce])
+
+ const formMethods = useForm({
+ defaultValues: {
+ ...params,
+ [TokenTransferFields.type]: disableSpendingLimit
+ ? TokenTransferType.multiSig
+ : isOnlySpendingLimitBeneficiary
+ ? TokenTransferType.spendingLimit
+ : params.type,
+ },
+ mode: 'onChange',
+ delayError: 500,
+ })
+
+ const {
+ handleSubmit,
+ setValue,
+ watch,
+ formState: { errors },
+ } = formMethods
+
+ const recipient = watch(TokenTransferFields.recipient)
+
+ // Selected token
+ const tokenAddress = watch(TokenAmountFields.tokenAddress)
+ const selectedToken = tokenAddress
+ ? balances.items.find((item) => item.tokenInfo.address === tokenAddress)
+ : undefined
+
+ const type = watch(TokenTransferFields.type)
+ const spendingLimit = useSpendingLimit(selectedToken?.tokenInfo)
+ const isSpendingLimitType = type === TokenTransferType.spendingLimit
+ const spendingLimitAmount = spendingLimit ? BigNumber.from(spendingLimit.amount).sub(spendingLimit.spent) : undefined
+ const totalAmount = BigNumber.from(selectedToken?.balance || 0)
+ const maxAmount = isSpendingLimitType
+ ? spendingLimitAmount && totalAmount.gt(spendingLimitAmount)
+ ? spendingLimitAmount
+ : totalAmount
+ : totalAmount
+
+ const balancesItems = useMemo(() => {
+ return isOnlySpendingLimitBeneficiary
+ ? balances.items.filter(({ tokenInfo }) => {
+ return spendingLimits?.some(({ beneficiary, token }) => {
+ return sameAddress(beneficiary, wallet?.address || '') && sameAddress(tokenInfo.address, token.address)
+ })
+ })
+ : balances.items
+ }, [balances.items, isOnlySpendingLimitBeneficiary, spendingLimits, wallet?.address])
+
+ const onMaxAmountClick = useCallback(() => {
+ if (!selectedToken) return
+
+ const amount =
+ isSpendingLimitType && spendingLimitAmount && spendingLimitAmount.lte(selectedToken.balance)
+ ? spendingLimitAmount.toString()
+ : selectedToken.balance
+
+ setValue(TokenAmountFields.amount, safeFormatUnits(amount, selectedToken.tokenInfo.decimals), {
+ shouldValidate: true,
+ })
+ }, [isSpendingLimitType, selectedToken, setValue, spendingLimitAmount])
+
+ const isSafeTokenSelected = sameAddress(safeTokenAddress, tokenAddress)
+ const isDisabled = isSafeTokenSelected && isSafeTokenPaused
+ const isAddressValid = !!recipient && !errors[TokenTransferFields.recipient]
+
+ return (
+
+
+
+
+
+ )
+}
+
+export default CreateTokenTransfer
diff --git a/src/components/tx/modals/TokenTransferModal/ReviewSpendingLimitTx.tsx b/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx
similarity index 72%
rename from src/components/tx/modals/TokenTransferModal/ReviewSpendingLimitTx.tsx
rename to src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx
index 2f139ad1b6..5a4ba8633c 100644
--- a/src/components/tx/modals/TokenTransferModal/ReviewSpendingLimitTx.tsx
+++ b/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx
@@ -1,11 +1,10 @@
import type { ReactElement, SyntheticEvent } from 'react'
-import { useMemo, useState } from 'react'
+import { useContext, useMemo, useState } from 'react'
import type { BigNumberish, BytesLike } from 'ethers'
-import { Button, DialogContent, Typography } from '@mui/material'
-import SendFromBlock from '@/components/tx/SendFromBlock'
-import SendToBlock from '@/components/tx/SendToBlock'
-import type { TokenTransferModalProps } from '.'
-import { TokenTransferReview } from '@/components/tx/modals/TokenTransferModal/ReviewTokenTx'
+import { Button, CardActions, Typography } from '@mui/material'
+import SendToBlock from '@/components/tx-flow/flows/TokenTransfer/SendToBlock'
+import { type TokenTransferParams } from '@/components/tx-flow/flows/TokenTransfer/index'
+import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock'
import useBalances from '@/hooks/useBalances'
import useSpendingLimit from '@/hooks/useSpendingLimit'
import useSpendingLimitGas from '@/hooks/useSpendingLimitGas'
@@ -21,6 +20,9 @@ import { getTxOptions } from '@/utils/transactions'
import { MODALS_EVENTS, trackEvent } from '@/services/analytics'
import useOnboard from '@/hooks/wallets/useOnboard'
import { WrongChainWarning } from '@/components/tx/WrongChainWarning'
+import { asError } from '@/services/exceptions/utils'
+import TxCard from '@/components/tx-flow/common/TxCard'
+import { TxModalContext } from '@/components/tx-flow'
export type SpendingLimitTxParams = {
safeAddress: string
@@ -33,9 +35,16 @@ export type SpendingLimitTxParams = {
signature: BytesLike
}
-const ReviewSpendingLimitTx = ({ params, onSubmit }: TokenTransferModalProps): ReactElement => {
+const ReviewSpendingLimitTx = ({
+ params,
+ onSubmit,
+}: {
+ params: TokenTransferParams
+ onSubmit: () => void
+}): ReactElement => {
const [isSubmittable, setIsSubmittable] = useState(true)
const [submitError, setSubmitError] = useState()
+ const { setTxFlow } = useContext(TxModalContext)
const currentChain = useCurrentChain()
const onboard = useOnboard()
const { safe, safeAddress } = useSafeInfo()
@@ -66,10 +75,7 @@ const ReviewSpendingLimitTx = ({ params, onSubmit }: TokenTransferModalProps): R
const { gasLimit, gasLimitLoading } = useSpendingLimitGas(txParams)
- const [advancedParams, setManualParams] = useAdvancedParams({
- gasLimit,
- nonce: params.txNonce,
- })
+ const [advancedParams, setManualParams] = useAdvancedParams(gasLimit)
const handleSubmit = async (e: SyntheticEvent) => {
e.preventDefault()
@@ -84,12 +90,13 @@ const ReviewSpendingLimitTx = ({ params, onSubmit }: TokenTransferModalProps): R
try {
await dispatchSpendingLimitTxExecution(txParams, txOptions, onboard, safe.chainId, safeAddress)
-
onSubmit()
- } catch (err) {
- logError(Errors._801, (err as Error).message)
+ setTxFlow(undefined)
+ } catch (_err) {
+ const err = asError(_err)
+ logError(Errors._801, err)
setIsSubmittable(true)
- setSubmitError(err as Error)
+ setSubmitError(err)
}
}
@@ -97,24 +104,18 @@ const ReviewSpendingLimitTx = ({ params, onSubmit }: TokenTransferModalProps): R
return (
)
}
diff --git a/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx b/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx
new file mode 100644
index 0000000000..2d8c3c33ce
--- /dev/null
+++ b/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx
@@ -0,0 +1,50 @@
+import { useContext, useEffect } from 'react'
+import useBalances from '@/hooks/useBalances'
+import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm'
+import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock'
+import SendToBlock from '@/components/tx-flow/flows/TokenTransfer/SendToBlock'
+import { createTokenTransferParams } from '@/services/tx/tokenTransferParams'
+import { createTx } from '@/services/tx/tx-sender'
+import type { TokenTransferParams } from '.'
+import { SafeTxContext } from '../../SafeTxProvider'
+
+const ReviewTokenTransfer = ({
+ params,
+ onSubmit,
+ txNonce,
+}: {
+ params: TokenTransferParams
+ onSubmit: () => void
+ txNonce?: number
+}) => {
+ const { setSafeTx, setSafeTxError, setNonce } = useContext(SafeTxContext)
+ const { balances } = useBalances()
+ const token = balances.items.find((item) => item.tokenInfo.address === params.tokenAddress)
+
+ useEffect(() => {
+ if (txNonce !== undefined) {
+ setNonce(txNonce)
+ }
+
+ if (!token) return
+
+ const txParams = createTokenTransferParams(
+ params.recipient,
+ params.amount,
+ token.tokenInfo.decimals,
+ token.tokenInfo.address,
+ )
+
+ createTx(txParams, txNonce).then(setSafeTx).catch(setSafeTxError)
+ }, [params, txNonce, token, setNonce, setSafeTx, setSafeTxError])
+
+ return (
+
+ {token && }
+
+
+
+ )
+}
+
+export default ReviewTokenTransfer
diff --git a/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx b/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx
new file mode 100644
index 0000000000..98e2535f31
--- /dev/null
+++ b/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx
@@ -0,0 +1,25 @@
+import { type ReactElement } from 'react'
+import { type TokenTransferParams, TokenTransferType } from '@/components/tx-flow/flows/TokenTransfer/index'
+import ReviewTokenTransfer from '@/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer'
+import ReviewSpendingLimitTx from '@/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx'
+
+// TODO: Split this into separate flows
+const ReviewTokenTx = ({
+ params,
+ onSubmit,
+ txNonce,
+}: {
+ params: TokenTransferParams
+ onSubmit: () => void
+ txNonce?: number
+}): ReactElement => {
+ const isSpendingLimitTx = params.type === TokenTransferType.spendingLimit
+
+ return isSpendingLimitTx ? (
+
+ ) : (
+
+ )
+}
+
+export default ReviewTokenTx
diff --git a/src/components/tx-flow/flows/TokenTransfer/SendAmountBlock.tsx b/src/components/tx-flow/flows/TokenTransfer/SendAmountBlock.tsx
new file mode 100644
index 0000000000..b5cd9a2301
--- /dev/null
+++ b/src/components/tx-flow/flows/TokenTransfer/SendAmountBlock.tsx
@@ -0,0 +1,57 @@
+import { type ReactNode } from 'react'
+import { type TokenInfo } from '@safe-global/safe-gateway-typescript-sdk'
+import { Grid, Typography } from '@mui/material'
+import css from './styles.module.css'
+import TokenIcon from '@/components/common/TokenIcon'
+import { formatAmountPrecise } from '@/utils/formatNumber'
+import { PSEUDO_APPROVAL_VALUES } from '@/components/tx/ApprovalEditor/utils/approvals'
+
+const AmountBlock = ({
+ amount,
+ tokenInfo,
+ children,
+}: {
+ amount: number | string
+ tokenInfo: Omit & { logoUri?: string }
+ children?: ReactNode
+}) => {
+ return (
+
+
+ {tokenInfo.symbol}
+ {children}
+ {amount === PSEUDO_APPROVAL_VALUES.UNLIMITED ? (
+ {PSEUDO_APPROVAL_VALUES.UNLIMITED}
+ ) : (
+ {formatAmountPrecise(amount, tokenInfo.decimals)}
+ )}
+
+ )
+}
+
+const SendAmountBlock = ({
+ amount,
+ tokenInfo,
+ children,
+ title = 'Send',
+}: {
+ amount: number | string
+ tokenInfo: Omit & { logoUri?: string }
+ children?: ReactNode
+ title?: string
+}) => {
+ return (
+
+
+
+ {title}
+
+
+
+ {children}
+
+
+ )
+}
+
+export default SendAmountBlock
diff --git a/src/components/tx-flow/flows/TokenTransfer/SendToBlock.tsx b/src/components/tx-flow/flows/TokenTransfer/SendToBlock.tsx
new file mode 100644
index 0000000000..8ae39346cd
--- /dev/null
+++ b/src/components/tx-flow/flows/TokenTransfer/SendToBlock.tsx
@@ -0,0 +1,21 @@
+import { Grid, Typography } from '@mui/material'
+import EthHashInfo from '@/components/common/EthHashInfo'
+
+const SendToBlock = ({ address, title = 'To' }: { address: string; title?: string }) => {
+ return (
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+ )
+}
+
+export default SendToBlock
diff --git a/src/components/tx-flow/flows/TokenTransfer/index.tsx b/src/components/tx-flow/flows/TokenTransfer/index.tsx
new file mode 100644
index 0000000000..e1fa557a0f
--- /dev/null
+++ b/src/components/tx-flow/flows/TokenTransfer/index.tsx
@@ -0,0 +1,69 @@
+import TxLayout from '@/components/tx-flow/common/TxLayout'
+import useTxStepper from '../../useTxStepper'
+import CreateTokenTransfer from './CreateTokenTransfer'
+import ReviewTokenTx from '@/components/tx-flow/flows/TokenTransfer/ReviewTokenTx'
+import AssetsIcon from '@/public/images/sidebar/assets.svg'
+import { ZERO_ADDRESS } from '@safe-global/safe-core-sdk/dist/src/utils/constants'
+import { TokenAmountFields } from '@/components/common/TokenAmountInput'
+
+export enum TokenTransferType {
+ multiSig = 'multiSig',
+ spendingLimit = 'spendingLimit',
+}
+
+enum Fields {
+ recipient = 'recipient',
+ type = 'type',
+}
+
+export const TokenTransferFields = { ...Fields, ...TokenAmountFields }
+
+export type TokenTransferParams = {
+ [TokenTransferFields.recipient]: string
+ [TokenTransferFields.tokenAddress]: string
+ [TokenTransferFields.amount]: string
+ [TokenTransferFields.type]: TokenTransferType
+}
+
+type TokenTransferFlowProps = Partial & {
+ txNonce?: number
+}
+
+const defaultParams: TokenTransferParams = {
+ recipient: '',
+ tokenAddress: ZERO_ADDRESS,
+ amount: '',
+ type: TokenTransferType.multiSig,
+}
+
+const TokenTransferFlow = ({ txNonce, ...params }: TokenTransferFlowProps) => {
+ const { data, step, nextStep, prevStep } = useTxStepper({
+ ...defaultParams,
+ ...params,
+ })
+
+ const steps = [
+ nextStep({ ...data, ...formData })}
+ />,
+
+ null} />,
+ ]
+
+ return (
+
+ {steps}
+
+ )
+}
+
+export default TokenTransferFlow
diff --git a/src/components/tx-flow/flows/TokenTransfer/styles.module.css b/src/components/tx-flow/flows/TokenTransfer/styles.module.css
new file mode 100644
index 0000000000..3330447b22
--- /dev/null
+++ b/src/components/tx-flow/flows/TokenTransfer/styles.module.css
@@ -0,0 +1,5 @@
+.token {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+}
diff --git a/src/components/settings/ContractVersion/UpdateSafeDialog.tsx b/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx
similarity index 54%
rename from src/components/settings/ContractVersion/UpdateSafeDialog.tsx
rename to src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx
index b424aaf16b..62239c188f 100644
--- a/src/components/settings/ContractVersion/UpdateSafeDialog.tsx
+++ b/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx
@@ -1,62 +1,31 @@
-import { Button, Typography } from '@mui/material'
-import { useState } from 'react'
+import { useContext, useEffect } from 'react'
+import { Typography } from '@mui/material'
+import ExternalLink from '@/components/common/ExternalLink'
import { LATEST_SAFE_VERSION } from '@/config/constants'
-
-import TxModal from '@/components/tx/TxModal'
-
-import useAsync from '@/hooks/useAsync'
-
-import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm'
-import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper'
-import type { SafeTransaction } from '@safe-global/safe-core-sdk-types'
-import { createUpdateSafeTxs } from '@/services/tx/safeUpdateParams'
-
-import useSafeInfo from '@/hooks/useSafeInfo'
import { useCurrentChain } from '@/hooks/useChains'
-import ExternalLink from '@/components/common/ExternalLink'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import { createUpdateSafeTxs } from '@/services/tx/safeUpdateParams'
import { createMultiSendCallOnlyTx } from '@/services/tx/tx-sender'
-import CheckWallet from '@/components/common/CheckWallet'
-
-const UpdateSafeSteps: TxStepperProps['steps'] = [
- {
- label: 'Update Safe Account version',
- render: (_, onSubmit) => ,
- },
-]
-
-const UpdateSafeDialog = () => {
- const [open, setOpen] = useState(false)
-
- const handleClose = () => setOpen(false)
-
- return (
- <>
-
- {(isOk) => (
- setOpen(true)} variant="contained" disabled={!isOk}>
- Update
-
- )}
-
- {open && }
- >
- )
-}
+import { SafeTxContext } from '../../SafeTxProvider'
+import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm'
-const ReviewUpdateSafeStep = ({ onSubmit }: { onSubmit: () => void }) => {
+export const UpdateSafeReview = () => {
const { safe, safeLoaded } = useSafeInfo()
const chain = useCurrentChain()
+ const { setSafeTx, setSafeTxError, setNonce } = useContext(SafeTxContext)
- const [safeTx, safeTxError] = useAsync(() => {
- if (!chain || !safeLoaded) return
+ useEffect(() => {
+ if (!chain || !safeLoaded) {
+ return
+ }
const txs = createUpdateSafeTxs(safe, chain)
- return createMultiSendCallOnlyTx(txs)
- }, [chain, safe, safeLoaded])
+ createMultiSendCallOnlyTx(txs).then(setSafeTx).catch(setSafeTxError)
+ }, [chain, safe, safeLoaded, setNonce, setSafeTx, setSafeTxError])
return (
-
+ null}>
Update now to take advantage of new features and the highest security standards available.
@@ -81,5 +50,3 @@ const ReviewUpdateSafeStep = ({ onSubmit }: { onSubmit: () => void }) => {
)
}
-
-export default UpdateSafeDialog
diff --git a/src/components/tx-flow/flows/UpdateSafe/index.tsx b/src/components/tx-flow/flows/UpdateSafe/index.tsx
new file mode 100644
index 0000000000..082272d015
--- /dev/null
+++ b/src/components/tx-flow/flows/UpdateSafe/index.tsx
@@ -0,0 +1,13 @@
+import TxLayout from '@/components/tx-flow/common/TxLayout'
+import { UpdateSafeReview } from './UpdateSafeReview'
+import SettingsIcon from '@/public/images/sidebar/settings.svg'
+
+const UpdateSafeFlow = () => {
+ return (
+
+
+
+ )
+}
+
+export default UpdateSafeFlow
diff --git a/src/components/tx-flow/index.tsx b/src/components/tx-flow/index.tsx
new file mode 100644
index 0000000000..28e4bf8267
--- /dev/null
+++ b/src/components/tx-flow/index.tsx
@@ -0,0 +1,77 @@
+import { createContext, type ReactElement, type ReactNode, useState, useEffect, useCallback } from 'react'
+import TxModalDialog from '@/components/common/TxModalDialog'
+import { useRouter } from 'next/router'
+
+const noop = () => {}
+
+type TxModalContextType = {
+ txFlow: JSX.Element | undefined
+ setTxFlow: (txFlow: TxModalContextType['txFlow'], onClose?: () => void, shouldWarn?: boolean) => void
+ setFullWidth: (fullWidth: boolean) => void
+}
+
+export const TxModalContext = createContext({
+ txFlow: undefined,
+ setTxFlow: noop,
+ setFullWidth: noop,
+})
+
+export const TxModalProvider = ({ children }: { children: ReactNode }): ReactElement => {
+ const [txFlow, setFlow] = useState(undefined)
+ const [shouldWarn, setShouldWarn] = useState(true)
+ const [, setOnClose] = useState[1]>(noop)
+ const [fullWidth, setFullWidth] = useState(false)
+ const router = useRouter()
+
+ const handleModalClose = useCallback(() => {
+ setOnClose((prevOnClose) => {
+ prevOnClose?.()
+ return noop
+ })
+ setFlow(undefined)
+ }, [setFlow, setOnClose])
+
+ const handleShowWarning = useCallback(() => {
+ if (!shouldWarn) {
+ handleModalClose()
+ return
+ }
+
+ const ok = confirm('Closing this window will discard your current progress.')
+ if (!ok) {
+ router.events.emit('routeChangeError')
+ throw 'routeChange aborted. This error can be safely ignored - https://github.com/zeit/next.js/issues/2476.'
+ }
+
+ handleModalClose()
+ }, [shouldWarn, handleModalClose, router])
+
+ const setTxFlow = useCallback(
+ (txFlow: TxModalContextType['txFlow'], onClose?: () => void, shouldWarn?: boolean) => {
+ setFlow(txFlow)
+ setOnClose(() => onClose ?? noop)
+ setShouldWarn(shouldWarn ?? true)
+ },
+ [setFlow, setOnClose],
+ )
+
+ // Show the confirmation dialog if user navigates
+ useEffect(() => {
+ if (!txFlow) return
+
+ router.events.on('routeChangeStart', handleShowWarning)
+ return () => {
+ router.events.off('routeChangeStart', handleShowWarning)
+ }
+ }, [txFlow, handleShowWarning, router])
+
+ return (
+
+ {children}
+
+
+ {txFlow}
+
+
+ )
+}
diff --git a/src/components/tx-flow/useTxStepper.tsx b/src/components/tx-flow/useTxStepper.tsx
new file mode 100644
index 0000000000..0b6ac457ce
--- /dev/null
+++ b/src/components/tx-flow/useTxStepper.tsx
@@ -0,0 +1,19 @@
+import { useCallback, useState } from 'react'
+
+const useTxStepper = (initialData: T) => {
+ const [step, setStep] = useState(0)
+ const [data, setData] = useState(initialData)
+
+ const nextStep = useCallback((entireData: T) => {
+ setData(entireData)
+ setStep((prevStep) => prevStep + 1)
+ }, [])
+
+ const prevStep = useCallback(() => {
+ setStep((prevStep) => prevStep - 1)
+ }, [])
+
+ return { step, data, nextStep, prevStep }
+}
+
+export default useTxStepper
diff --git a/src/components/tx/AdvancedParams/AdvancedParamsForm.tsx b/src/components/tx/AdvancedParams/AdvancedParamsForm.tsx
index 27de329299..c68716ff54 100644
--- a/src/components/tx/AdvancedParams/AdvancedParamsForm.tsx
+++ b/src/components/tx/AdvancedParams/AdvancedParamsForm.tsx
@@ -4,7 +4,6 @@ import { BigNumber } from 'ethers'
import { FormProvider, useForm } from 'react-hook-form'
import { safeFormatUnits, safeParseUnits } from '@/utils/formatters'
import { FLOAT_REGEX } from '@/utils/validation'
-import NonceForm from '../NonceForm'
import ModalDialog from '@/components/common/ModalDialog'
import { AdvancedField, type AdvancedParameters } from './types.d'
import GasLimitInput from './GasLimitInput'
@@ -15,33 +14,27 @@ import { HelpCenterArticle } from '@/config/constants'
type AdvancedParamsFormProps = {
params: AdvancedParameters
onSubmit: (params: AdvancedParameters) => void
- recommendedNonce?: number
recommendedGasLimit?: AdvancedParameters['gasLimit']
isExecution: boolean
isEIP1559: boolean
- nonceReadonly?: boolean
willRelay?: boolean
}
type FormData = {
- [AdvancedField.nonce]: number
[AdvancedField.userNonce]: number
[AdvancedField.gasLimit]?: string
[AdvancedField.maxFeePerGas]: string
[AdvancedField.maxPriorityFeePerGas]: string
- [AdvancedField.safeTxGas]: number
}
const AdvancedParamsForm = ({ params, ...props }: AdvancedParamsFormProps) => {
const formMethods = useForm({
mode: 'onChange',
defaultValues: {
- nonce: params.nonce,
userNonce: params.userNonce || 0,
gasLimit: params.gasLimit?.toString() || undefined,
maxFeePerGas: params.maxFeePerGas ? safeFormatUnits(params.maxFeePerGas) : '',
maxPriorityFeePerGas: params.maxPriorityFeePerGas ? safeFormatUnits(params.maxPriorityFeePerGas) : '',
- safeTxGas: params.safeTxGas,
},
})
const {
@@ -52,23 +45,19 @@ const AdvancedParamsForm = ({ params, ...props }: AdvancedParamsFormProps) => {
const onBack = () => {
props.onSubmit({
- nonce: params.nonce,
userNonce: params.userNonce,
gasLimit: params.gasLimit,
maxFeePerGas: params.maxFeePerGas,
maxPriorityFeePerGas: params.maxPriorityFeePerGas,
- safeTxGas: params.safeTxGas,
})
}
const onSubmit = (data: FormData) => {
props.onSubmit({
- nonce: data.nonce,
userNonce: data.userNonce,
gasLimit: data.gasLimit ? BigNumber.from(data.gasLimit) : undefined,
maxFeePerGas: safeParseUnits(data.maxFeePerGas) || params.maxFeePerGas,
maxPriorityFeePerGas: safeParseUnits(data.maxPriorityFeePerGas) || params.maxPriorityFeePerGas,
- safeTxGas: data.safeTxGas || params.safeTxGas,
})
}
@@ -84,100 +73,59 @@ const AdvancedParamsForm = ({ params, ...props }: AdvancedParamsFormProps) => {
)
}
-export default DecodedTx
+export default memo(DecodedTx)
diff --git a/src/components/tx/ErrorMessage/index.tsx b/src/components/tx/ErrorMessage/index.tsx
index 4d2eea859b..5f6cc07932 100644
--- a/src/components/tx/ErrorMessage/index.tsx
+++ b/src/components/tx/ErrorMessage/index.tsx
@@ -14,7 +14,7 @@ const ErrorMessage = ({
children: ReactNode
error?: Error & { reason?: string }
className?: string
- level?: 'error' | 'info'
+ level?: 'error' | 'warning' | 'info'
}): ReactElement => {
const [showDetails, setShowDetails] = useState