Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TX flow] Adds nonce validation #2175

Merged
merged 9 commits into from
Jun 29, 2023
192 changes: 120 additions & 72 deletions src/components/tx-flow/common/TxNonce/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { memo, type ReactElement, type SyntheticEvent, useCallback, useContext, useMemo, useRef, useState } from 'react'

import { memo, type ReactElement, useContext, useMemo } from 'react'
import {
Autocomplete,
Box,
Expand All @@ -9,20 +8,23 @@ import {
Tooltip,
Popper,
type PopperProps,
type AutocompleteValue,
type MenuItemProps,
MenuItem,
} from '@mui/material'
import { SafeTxContext } from '../../SafeTxProvider'
import { Controller, useForm } from 'react-hook-form'

import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider'
import RotateLeftIcon from '@mui/icons-material/RotateLeft'
import NumberField from '@/components/common/NumberField'
import { useQueuedTxByNonce } from '@/hooks/useTxQueue'
import useSafeInfo from '@/hooks/useSafeInfo'
import css from './styles.module.css'
import useAddressBook from '@/hooks/useAddressBook'
import { getLatestTransactions } from '@/utils/tx-list'
import { getTransactionType } from '@/hooks/useTransactionType'
import usePreviousNonces from '@/hooks/usePreviousNonces'
import { isRejectionTx } from '@/utils/transactions'

import css from './styles.module.css'

const CustomPopper = function (props: PopperProps) {
return <Popper {...props} sx={{ width: '300px !important' }} placement="bottom-start" />
Expand Down Expand Up @@ -50,84 +52,130 @@ const NonceFormOption = memo(function NonceFormOption({
)
})

const TxNonce = () => {
const [error, setError] = useState<boolean>(false)
const { safe } = useSafeInfo()
enum TxNonceFormFieldNames {
NONCE = 'nonce',
}

const TxNonceForm = ({ nonce, recommendedNonce }: { nonce: number; recommendedNonce: number }) => {
const { safeTx, setNonce } = useContext(SafeTxContext)
const previousNonces = usePreviousNonces()
const { nonce, setNonce, safeTx, recommendedNonce } = useContext(SafeTxContext)
const isEmpty = useRef<boolean>(false)
const { safe } = useSafeInfo()

const isEditable = !safeTx || safeTx?.signatures.size === 0
const readonly = !isEditable
const readOnly = !isEditable || isRejectionTx(safeTx)

const isValidInput = useCallback(
(value: string | AutocompleteValue<unknown, false, false, false>) => {
return Number(value) >= safe.nonce
const formMethods = useForm({
defaultValues: {
[TxNonceFormFieldNames.NONCE]: nonce.toString(),
},
[safe.nonce],
)
mode: 'all',
})

const handleChange = useCallback(
(_e: SyntheticEvent, value: string | AutocompleteValue<unknown, false, false, false>) => {
isEmpty.current = value === ''
const nonce = Number(value)
if (isNaN(nonce)) return
setError(!isValidInput(value))
setNonce(nonce)
},
[isValidInput, setNonce],
)
const resetNonce = () => {
formMethods.setValue(TxNonceFormFieldNames.NONCE, recommendedNonce.toString())
}

return (
<Controller
name={TxNonceFormFieldNames.NONCE}
control={formMethods.control}
rules={{
required: 'Nonce is required',
// Validation must be async to allow resetting invalid values onBlur
validate: async (value) => {
const newNonce = Number(value)

if (isNaN(newNonce)) {
return 'Nonce must be a number'
}

const resetNonce = useCallback(() => {
setError(false)
isEmpty.current = false
setNonce(recommendedNonce)
}, [recommendedNonce, setNonce])
if (newNonce < safe.nonce) {
return `Nonce can't be lower than ${safe.nonce}`
}

if (nonce === undefined) return <Skeleton variant="rounded" width={40} height={38} />
if (newNonce >= Number.MAX_SAFE_INTEGER) {
return 'Nonce is too high'
}

// Update contect with valid nonce
setNonce(newNonce)
},
}}
render={({ field, fieldState }) => {
return (
<Autocomplete
value={field.value}
freeSolo
onChange={(_, value) => field.onChange(value)}
onInputChange={(_, value) => field.onChange(value)}
onBlur={() => {
field.onBlur()

if (fieldState.error) {
formMethods.setValue(field.name, recommendedNonce.toString())
}
}}
options={previousNonces}
disabled={readOnly}
getOptionLabel={(option) => option.toString()}
renderOption={(props, option) => {
return <NonceFormOption menuItemProps={props} nonce={option} />
}}
disableClearable
componentsProps={{
paper: {
elevation: 2,
},
}}
renderInput={(params) => {
return (
<Tooltip title={fieldState.error?.message} open arrow placement="top">
<NumberField
{...params}
error={!!fieldState.error}
InputProps={{
...params.InputProps,
endAdornment: (
<InputAdornment position="end" className={css.adornment}>
<Tooltip title="Reset to recommended nonce">
<IconButton
onClick={resetNonce}
size="small"
color="primary"
disabled={readOnly || recommendedNonce.toString() === field.value}
>
<RotateLeftIcon fontSize="small" />
</IconButton>
</Tooltip>
</InputAdornment>
),
readOnly,
}}
className={css.input}
sx={{ width: `${field.value.length}em`, minWidth: '5em', maxWidth: '200px' }}
/>
</Tooltip>
)
}}
PopperComponent={CustomPopper}
/>
)
}}
/>
)
}

const TxNonce = () => {
const { nonce, recommendedNonce } = useContext(SafeTxContext)

return (
<Box display="flex" alignItems="center" gap={1}>
Nonce #
<Autocomplete
value={isEmpty.current ? '' : nonce}
inputValue={isEmpty.current ? '' : nonce.toString()}
freeSolo
onChange={handleChange}
onInputChange={handleChange}
options={previousNonces}
disabled={readonly}
getOptionLabel={(option) => option.toString()}
renderOption={(props, option: number) => <NonceFormOption menuItemProps={props} nonce={option} />}
disableClearable
componentsProps={{
paper: {
elevation: 2,
},
}}
renderInput={(params) => (
<NumberField
{...params}
error={error}
InputProps={{
...params.InputProps,
name: 'nonce',
endAdornment: !readonly && recommendedNonce !== undefined && recommendedNonce !== nonce && (
<InputAdornment position="end" className={css.adornment}>
<Tooltip title="Reset to recommended nonce">
<IconButton onClick={resetNonce} size="small" color="primary">
<RotateLeftIcon fontSize="small" />
</IconButton>
</Tooltip>
</InputAdornment>
),
readOnly: readonly,
}}
className={css.input}
sx={{ minWidth: `${nonce.toString().length + 0.5}em` }}
/>
)}
PopperComponent={CustomPopper}
/>
{nonce === undefined || recommendedNonce === undefined ? (
<Skeleton width="70px" height="38px" />
) : (
<TxNonceForm nonce={nonce} recommendedNonce={recommendedNonce} />
)}
</Box>
)
}
Expand Down
1 change: 0 additions & 1 deletion src/components/tx-flow/common/TxNonce/styles.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

.input input {
font-weight: bold;
text-align: center;
padding-right: 6px !important;
}

Expand Down
12 changes: 10 additions & 2 deletions src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ReactElement, useCallback, useMemo, useState } from 'react'
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'
Expand All @@ -24,6 +24,7 @@ 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 => (
<Grid container alignItems="center" gap={1}>
Expand Down Expand Up @@ -57,8 +58,15 @@ const CreateTokenTransfer = ({
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<TokenTransferParams>({
defaultValues: {
...params,
Expand Down Expand Up @@ -157,7 +165,7 @@ const CreateTokenTransfer = ({
/>

{isDisabled && (
<Box mt={1} display="flex" alignItems="center">
<Box display="flex" alignItems="center" mt={-2} mb={3}>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes the issue that the safe error is too close to the divider

<SvgIcon component={InfoIcon} color="error" fontSize="small" />
<Typography variant="body2" color="error" ml={0.5}>
$SAFE is currently non-transferable.
Expand Down
5 changes: 5 additions & 0 deletions src/utils/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { Multi_send__factory } from '@/types/contracts'
import { ethers } from 'ethers'
import { type BaseTransaction } from '@safe-global/safe-apps-sdk'
import { id } from 'ethers/lib/utils'
import { isEmptyHexData } from '@/utils/hex'

export const makeTxFromDetails = (txDetails: TransactionDetails): Transaction => {
const getMissingSigners = ({
Expand Down Expand Up @@ -266,3 +267,7 @@ export const decodeMultiSendTxs = (encodedMultiSendData: string): BaseTransactio

return txs
}

export const isRejectionTx = (tx?: SafeTransaction) => {
return !!tx && !!tx.data.data && !!isEmptyHexData(tx.data.data) && tx.data.value === '0'
}