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 ( + +
+ + + Interact with any contract by providing the address and calldata. + + + validateAddress(value) || undefined} + required + /> + + + + Balance: {safeFormatUnits(nativeToken.balance, nativeToken.tokenInfo.decimals)}{' '} + {nativeToken.tokenInfo.symbol} + + )) + } + InputProps={{ + endAdornment: ( + + {chain?.nativeCurrency.symbol || 'ETH'} + + ), + }} + /> + + + + validateHexData(value), + })} + label="Calldata" + multiline + rows={4} + fullWidth + error={!!errors.calldata} + helperText={errors.calldata?.message} + InputProps={{ + endAdornment: ( + + + + + + + + ), + sx: { alignItems: 'flex-start' }, + }} + /> + + + + Tip: You can copy the transaction data to simulate it in external tools before execution. + + + + + + +
+
+ ) +} + +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 =