diff --git a/Dockerfile b/Dockerfile
index 101be1706..614da15e3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:18-alpine
+FROM node:20-alpine
RUN apk add --no-cache libc6-compat git python3 py3-pip make g++ libusb-dev eudev-dev linux-headers
WORKDIR /app
COPY . .
diff --git a/src/components/tx-flow/common/TxButton.tsx b/src/components/tx-flow/common/TxButton.tsx
index 5bafce5b0..a299b7a98 100644
--- a/src/components/tx-flow/common/TxButton.tsx
+++ b/src/components/tx-flow/common/TxButton.tsx
@@ -34,3 +34,11 @@ export const SendNFTsButton = () => {
)
}
+
+export const CustomTransactionButton = ({ onClick, sx }: { onClick: () => void; sx?: ButtonProps['sx'] }) => {
+ return (
+
+ )
+}
diff --git a/src/components/tx-flow/flows/CustomTransaction/CreateCustomTx.tsx b/src/components/tx-flow/flows/CustomTransaction/CreateCustomTx.tsx
new file mode 100644
index 000000000..0b05f0cae
--- /dev/null
+++ b/src/components/tx-flow/flows/CustomTransaction/CreateCustomTx.tsx
@@ -0,0 +1,170 @@
+import { useCallback } from 'react'
+import { useForm, FormProvider } from 'react-hook-form'
+import { Button, Typography, Divider, Box, TextField, InputAdornment, Tooltip, IconButton, SvgIcon } from '@mui/material'
+import AddressInput from '@/components/common/AddressInput'
+import TxCard from '../../common/TxCard'
+import { validateAddress, validateHexData } from '@/utils/validation'
+import { parseUnits } from '@ethersproject/units'
+import type { CustomTransactionParams } from '.'
+import ContentCopyIcon from '@mui/icons-material/ContentCopy'
+import { useCurrentChain } from '@/hooks/useChains'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import { safeFormatUnits } from '@/utils/formatters'
+import useBalances from '@/hooks/useBalances'
+
+export type CustomTxParams = {
+ contractAddress: string
+ value: string
+ calldata: string
+ nonce?: number
+}
+
+const CreateCustomTx = ({
+ params,
+ onSubmit,
+ txNonce,
+}: {
+ params: CustomTransactionParams
+ onSubmit: (data: CustomTxParams) => void
+ txNonce?: number
+}) => {
+ const chain = useCurrentChain()
+ const { safe } = useSafeInfo()
+ const { balances } = useBalances()
+ const nativeToken = balances.find((item) => item.tokenInfo.type === 'NATIVE_TOKEN')
+
+ const formMethods = useForm({
+ defaultValues: {
+ contractAddress: params.contractAddress,
+ value: params.value,
+ calldata: params.calldata || '0x',
+ nonce: txNonce,
+ },
+ mode: 'onChange',
+ })
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ formState: { errors, isValid },
+ } = formMethods
+
+ const contractAddress = watch('contractAddress')
+ const value = watch('value')
+ const calldata = watch('calldata')
+
+ const validateValue = useCallback(
+ (value: string) => {
+ if (!value || value === '0') return
+
+ try {
+ const valueBN = parseUnits(value, nativeToken?.tokenInfo.decimals || 18)
+ const balance = nativeToken?.balance || '0'
+
+ if (valueBN.gt(balance)) {
+ return 'Insufficient balance'
+ }
+ } catch (e) {
+ return 'Invalid amount'
+ }
+ },
+ [nativeToken],
+ )
+
+ const onCopyCalldata = () => {
+ navigator.clipboard.writeText(calldata)
+ }
+
+ const handleFormSubmit = (data: CustomTxParams) => {
+ onSubmit({
+ ...data,
+ value: data.value || '0',
+ })
+ }
+
+ return (
+
+
+
+ )
+}
+
+export default CreateCustomTx
\ No newline at end of file
diff --git a/src/components/tx-flow/flows/CustomTransaction/ReviewCustomTx.tsx b/src/components/tx-flow/flows/CustomTransaction/ReviewCustomTx.tsx
new file mode 100644
index 000000000..2a1c71eed
--- /dev/null
+++ b/src/components/tx-flow/flows/CustomTransaction/ReviewCustomTx.tsx
@@ -0,0 +1,91 @@
+import { useContext, useEffect } from 'react'
+import type { CustomTransactionParams } from '.'
+import { encodeAbiParameters, parseAbiParameters, type Hex } from 'viem'
+import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm'
+import { SafeTxContext } from '../../SafeTxProvider'
+import { createTx } from '@/services/tx/tx-sender'
+import { useCurrentChain } from '@/hooks/useChains'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import { parseUnits } from '@ethersproject/units'
+import TxCard from '../../common/TxCard'
+import { Box, Typography, Divider } from '@mui/material'
+import { HexEncodedData } from '@/components/transactions/HexEncodedData'
+import EthHashInfo from '@/components/common/EthHashInfo'
+import { safeFormatUnits } from '@/utils/formatters'
+
+const ReviewCustomTx = ({
+ params,
+ txNonce,
+ onSubmit,
+}: {
+ params: CustomTransactionParams
+ txNonce?: number
+ onSubmit: () => void
+}) => {
+ const { setSafeTx, safeTxError, setSafeTxError } = useContext(SafeTxContext)
+ const chain = useCurrentChain()
+ const { safe } = useSafeInfo()
+
+ useEffect(() => {
+ if (!params.contractAddress || !chain || !safe) return
+
+ const value = params.value || '0'
+ const data = params.calldata || '0x'
+
+ createTx({
+ to: params.contractAddress,
+ value: parseUnits(value, chain.nativeCurrency.decimals).toString(),
+ data,
+ nonce: txNonce,
+ })
+ .then(setSafeTx)
+ .catch(setSafeTxError)
+ }, [params, chain, safe, txNonce, setSafeTx, setSafeTxError])
+
+ const displayValue = params.value && params.value !== '0'
+ ? `${params.value} ${chain?.nativeCurrency.symbol || 'ETH'}`
+ : undefined
+
+ return (
+
+
+
+ Review transaction
+
+
+
+
+ Contract
+
+
+
+
+ {displayValue && (
+
+
+ Value
+
+ {displayValue}
+
+ )}
+
+
+
+ Calldata
+
+
+
+
+ {safeTxError && (
+
+
+ {safeTxError.message}
+
+
+ )}
+
+
+ )
+}
+
+export default ReviewCustomTx
\ No newline at end of file
diff --git a/src/components/tx-flow/flows/CustomTransaction/index.tsx b/src/components/tx-flow/flows/CustomTransaction/index.tsx
new file mode 100644
index 000000000..416bd3092
--- /dev/null
+++ b/src/components/tx-flow/flows/CustomTransaction/index.tsx
@@ -0,0 +1,50 @@
+import TxLayout from '@/components/tx-flow/common/TxLayout'
+import useTxStepper from '../../useTxStepper'
+import CreateCustomTx from './CreateCustomTx'
+import ReviewCustomTx from './ReviewCustomTx'
+import { SvgIcon } from '@mui/material'
+import SettingsIcon from '@/public/images/sidebar/settings.svg'
+
+export type CustomTransactionParams = {
+ contractAddress: string
+ value: string
+ calldata: string
+}
+
+const defaultParams: CustomTransactionParams = {
+ contractAddress: '',
+ value: '0',
+ calldata: '',
+}
+
+type CustomTransactionFlowProps = {
+ txNonce?: number
+}
+
+const CustomTransactionFlow = ({ txNonce }: CustomTransactionFlowProps) => {
+ const { data, step, nextStep, prevStep } = useTxStepper(defaultParams)
+
+ const steps = [
+ nextStep({ ...data, ...formData })}
+ />,
+ null} />,
+ ]
+
+ return (
+
+ {steps}
+
+ )
+}
+
+export default CustomTransactionFlow
\ No newline at end of file
diff --git a/src/components/tx-flow/flows/NewTx/index.tsx b/src/components/tx-flow/flows/NewTx/index.tsx
index fa1a3fc17..76a340968 100644
--- a/src/components/tx-flow/flows/NewTx/index.tsx
+++ b/src/components/tx-flow/flows/NewTx/index.tsx
@@ -1,9 +1,11 @@
import { useCallback, useContext } from 'react'
-import { SendNFTsButton, SendTokensButton } from '@/components/tx-flow/common/TxButton'
+import { SendNFTsButton, SendTokensButton, CustomTransactionButton } from '@/components/tx-flow/common/TxButton'
import { Container, Grid, Paper, SvgIcon, Typography } from '@mui/material'
import { TxModalContext } from '../../'
import TokenTransferFlow from '../TokenTransfer'
+import CustomTransactionFlow from '../CustomTransaction'
import AssetsIcon from '@/public/images/sidebar/assets.svg'
+import SettingsIcon from '@/public/images/sidebar/settings.svg'
import { ProgressBar } from '@/components/common/ProgressBar'
import ChainIndicator from '@/components/common/ChainIndicator'
import NewTxIcon from '@/public/images/transactions/new-tx.svg'
@@ -17,6 +19,10 @@ const NewTxFlow = () => {
setTxFlow()
}, [setTxFlow])
+ const onCustomClick = useCallback(() => {
+ setTxFlow()
+ }, [setTxFlow])
+
const progress = 10
return (
@@ -49,6 +55,13 @@ const NewTxFlow = () => {
+
+
+
+ Contract interaction
+
+
+
diff --git a/src/components/tx-flow/flows/index.ts b/src/components/tx-flow/flows/index.ts
index 7cd4bf646..9a93bc732 100644
--- a/src/components/tx-flow/flows/index.ts
+++ b/src/components/tx-flow/flows/index.ts
@@ -17,3 +17,4 @@ export const ReplaceTxFlow = dynamic(() => import('./ReplaceTx'))
export const SuccessScreenFlow = dynamic(() => import('./SuccessScreen'))
export const TokenTransferFlow = dynamic(() => import('./TokenTransfer'))
export const UpdateSafeFlow = dynamic(() => import('./UpdateSafe'))
+export const CustomTransactionFlow = dynamic(() => import('./CustomTransaction'))
diff --git a/src/utils/validation.ts b/src/utils/validation.ts
index 61e151881..630acfc87 100644
--- a/src/utils/validation.ts
+++ b/src/utils/validation.ts
@@ -13,6 +13,27 @@ export const validateAddress = (address: string) => {
}
}
+export const validateHexData = (data: string) => {
+ // Trim whitespace
+ const trimmedData = data.trim()
+
+ // Allow empty calldata
+ if (trimmedData === '' || trimmedData === '0x') {
+ return undefined
+ }
+
+ const HEX_RE = /^0x[0-9a-f]*$/i
+
+ if (!HEX_RE.test(trimmedData)) {
+ return 'Invalid hex data format'
+ }
+
+ // Check if the hex string (excluding 0x) has even number of characters
+ if ((trimmedData.length - 2) % 2 !== 0) {
+ return 'Hex data must have even number of characters'
+ }
+}
+
export const isValidAddress = (address: string): boolean => validateAddress(address) === undefined
export const validatePrefixedAddress =