Skip to content

Commit

Permalink
[TX flow] Adds nonce validation (#2175)
Browse files Browse the repository at this point in the history
* Adds nonce validation

* Adjust nonce validation

* Disable nonce for reject tx, use correct nonce when replacing a tx

* Adjust wording

* fix: migrate input to RHF

* Limit nonce input width

* Fix e2e tests

---------

Co-authored-by: iamacook <[email protected]>
  • Loading branch information
usame-algan and iamacook committed Jun 29, 2023
1 parent 9bfaa87 commit 75f142d
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 75 deletions.
193 changes: 121 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,131 @@ 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,
name: field.name,
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}>
<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'
}

0 comments on commit 75f142d

Please sign in to comment.