diff --git a/src/components/tx-flow/common/TxNonce/index.tsx b/src/components/tx-flow/common/TxNonce/index.tsx
index 1a28af4e5d..53e2118271 100644
--- a/src/components/tx-flow/common/TxNonce/index.tsx
+++ b/src/components/tx-flow/common/TxNonce/index.tsx
@@ -1,29 +1,132 @@
-import { type ChangeEvent, useCallback, useContext } from 'react'
-import { Skeleton, Typography } from '@mui/material'
+import { memo, type ReactElement, type SyntheticEvent, useCallback, useContext, useMemo, useRef, useState } from 'react'
+
+import {
+ Autocomplete,
+ Box,
+ IconButton,
+ InputAdornment,
+ Skeleton,
+ Tooltip,
+ Popper,
+ type PopperProps,
+ type AutocompleteValue,
+ type MenuItemProps,
+ MenuItem,
+} from '@mui/material'
import { SafeTxContext } from '../../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'
+
+const CustomPopper = function (props: PopperProps) {
+ return
+}
+
+const NonceFormOption = memo(function NonceFormOption({
+ nonce,
+ menuItemProps,
+}: {
+ nonce: number
+ menuItemProps: MenuItemProps
+}): ReactElement {
+ const addressBook = useAddressBook()
+ const transactions = useQueuedTxByNonce(nonce)
+
+ const label = useMemo(() => {
+ const [{ transaction }] = getLatestTransactions(transactions)
+ return getTransactionType(transaction, addressBook).text
+ }, [addressBook, transactions])
+
+ return (
+
+ )
+})
const TxNonce = () => {
- const { nonce, setNonce, safeTx } = useContext(SafeTxContext)
+ const [error, setError] = useState(false)
+ const { safe } = useSafeInfo()
+ const previousNonces = usePreviousNonces()
+ const { nonce, setNonce, safeTx, recommendedNonce } = useContext(SafeTxContext)
+ const isEmpty = useRef(false)
const isEditable = !safeTx || safeTx?.signatures.size === 0
+ const readonly = !isEditable
+
+ const isValidInput = useCallback(
+ (value: string | AutocompleteValue) => {
+ return Number(value) >= safe.nonce
+ },
+ [safe.nonce],
+ )
- const onChange = useCallback(
- (e: ChangeEvent) => {
- const newNonce = Number(e.target.textContent)
- setNonce(newNonce)
+ const handleChange = useCallback(
+ (e: SyntheticEvent, value: string | AutocompleteValue) => {
+ isEmpty.current = value === ''
+ const nonce = Number(value)
+ if (isNaN(nonce)) return
+ setError(!isValidInput(value))
+ setNonce(nonce)
},
- [setNonce],
+ [isValidInput, setNonce],
)
+ const resetNonce = useCallback(() => {
+ setError(false)
+ isEmpty.current = false
+ setNonce(recommendedNonce)
+ }, [recommendedNonce, setNonce])
+
if (nonce === undefined) return
return (
-
- #
-
- {nonce}
-
-
+
+ Nonce
+ option.toString()}
+ renderOption={(props, option: number) => }
+ disableClearable
+ componentsProps={{
+ paper: {
+ elevation: 2,
+ },
+ }}
+ renderInput={(params) => (
+
+
+
+
+
+
+
+ ),
+ readOnly: readonly,
+ }}
+ className={css.input}
+ />
+ )}
+ PopperComponent={CustomPopper}
+ />
+
)
}
diff --git a/src/components/tx-flow/common/TxNonce/styles.module.css b/src/components/tx-flow/common/TxNonce/styles.module.css
index 0ee677aad7..63cbd48d7d 100644
--- a/src/components/tx-flow/common/TxNonce/styles.module.css
+++ b/src/components/tx-flow/common/TxNonce/styles.module.css
@@ -1,14 +1,8 @@
-.input {
- white-space: nowrap;
- width: 200px;
- overflow: hidden;
+.input :global .MuiOutlinedInput-root {
+ padding: 0;
}
-.input br {
- display: none;
-}
-
-.input * {
- display: inline;
- white-space: nowrap;
+.adornment {
+ margin-left: 0;
+ margin-right: 4px;
}
diff --git a/src/components/tx/NonceForm/index.tsx b/src/components/tx/NonceForm/index.tsx
deleted file mode 100644
index 67421f21c8..0000000000
--- a/src/components/tx/NonceForm/index.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import { memo, useMemo } from 'react'
-import type { ReactElement } from 'react'
-import { useController, useFormContext, useWatch } from 'react-hook-form'
-import { Autocomplete, IconButton, InputAdornment, MenuItem, Tooltip } from '@mui/material'
-import RotateLeftIcon from '@mui/icons-material/RotateLeft'
-import useSafeInfo from '@/hooks/useSafeInfo'
-import NumberField from '@/components/common/NumberField'
-import useTxQueue, { useQueuedTxByNonce } from '@/hooks/useTxQueue'
-import { isMultisigExecutionInfo, isTransactionListItem } from '@/utils/transaction-guards'
-import { uniqBy } from 'lodash'
-import { getTransactionType } from '@/hooks/useTransactionType'
-import useAddressBook from '@/hooks/useAddressBook'
-import { getLatestTransactions } from '@/utils/tx-list'
-import type { MenuItemProps } from '@mui/material'
-
-type NonceFormProps = {
- name: string
- nonce: number
- recommendedNonce?: number
- readonly?: boolean
-}
-
-const NonceFormOption = memo(function NonceFormOption({
- nonce,
- menuItemProps,
-}: {
- nonce: number
- menuItemProps: MenuItemProps
-}): ReactElement {
- const addressBook = useAddressBook()
- const transactions = useQueuedTxByNonce(nonce)
-
- const label = useMemo(() => {
- const [{ transaction }] = getLatestTransactions(transactions)
- return getTransactionType(transaction, addressBook).text
- }, [addressBook, transactions])
-
- return (
-
- )
-})
-
-const NonceForm = ({ name, nonce, recommendedNonce, readonly }: NonceFormProps): ReactElement => {
- const { safe } = useSafeInfo()
- const safeNonce = safe.nonce || 0
-
- // Initialise form field
- const { setValue, control } = useFormContext() || {}
- const {
- field: { ref, onBlur, onChange, value },
- fieldState,
- } = useController({
- name,
- control,
- defaultValue: nonce,
- rules: {
- required: true,
- validate: (val: number) => {
- if (!Number.isInteger(val)) {
- return 'Nonce must be an integer'
- } else if (val < safeNonce) {
- return `Nonce can't be lower than ${safeNonce}`
- }
- },
- },
- })
-
- // Autocomplete options
- const { page } = useTxQueue()
- const queuedTxs = useMemo(() => {
- if (!page || page.results.length === 0) {
- return []
- }
-
- const txs = page.results.filter(isTransactionListItem).map((item) => item.transaction)
-
- return uniqBy(txs, (tx) => {
- return isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : ''
- })
- }, [page])
-
- const options = useMemo(() => {
- return queuedTxs
- .map((tx) => (isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : undefined))
- .filter((nonce) => nonce !== undefined)
- }, [queuedTxs])
-
- // Warn about a higher nonce
- const editableNonce = useWatch({ name, control, exact: true })
- const nonceWarning =
- recommendedNonce != null && editableNonce > recommendedNonce ? `Recommended nonce is ${recommendedNonce}` : ''
- const label = fieldState.error?.message || nonceWarning || 'Safe Account transaction nonce'
-
- const onResetNonce = () => {
- if (recommendedNonce != null) {
- setValue(name, recommendedNonce, { shouldValidate: true })
- }
- }
-
- return (
- {
- onChange(value ? Number(value) : '')
- }}
- options={options}
- disabled={nonce == null || readonly}
- getOptionLabel={(option) => option.toString()}
- renderOption={(props, option: number) => }
- disableClearable
- componentsProps={{
- paper: {
- elevation: 2,
- },
- }}
- renderInput={(params) => (
-
-
-
-
-
-
-
- ),
- readOnly: readonly,
- }}
- InputLabelProps={{
- ...params.InputLabelProps,
- shrink: true,
- }}
- />
- )}
- />
- )
-}
-
-export default NonceForm
diff --git a/src/hooks/__tests__/useBatchedTxs.test.ts b/src/hooks/__tests__/useBatchedTxs.test.ts
index 5b8d6c33dd..67480d5033 100644
--- a/src/hooks/__tests__/useBatchedTxs.test.ts
+++ b/src/hooks/__tests__/useBatchedTxs.test.ts
@@ -1,67 +1,7 @@
-import type {
- AddressEx,
- MultisigExecutionInfo,
- Transaction,
- TransactionInfo,
- TransactionListItem,
- TransactionSummary,
- TransferInfo,
-} from '@safe-global/safe-gateway-typescript-sdk'
-import {
- ConflictType,
- DetailedExecutionInfoType,
- TransactionInfoType,
- TransactionListItemType,
- TransactionStatus,
- TransactionTokenType,
- TransferDirection,
-} from '@safe-global/safe-gateway-typescript-sdk'
+import type { MultisigExecutionInfo, Transaction, TransactionListItem } from '@safe-global/safe-gateway-typescript-sdk'
+import { ConflictType, TransactionListItemType } from '@safe-global/safe-gateway-typescript-sdk'
import { getBatchableTransactions } from '@/hooks/useBatchedTxs'
-
-const mockAddressEx: AddressEx = {
- value: 'string',
-}
-
-const mockTransferInfo: TransferInfo = {
- type: TransactionTokenType.ERC20,
- tokenAddress: 'string',
- value: 'string',
-}
-
-const mockTxInfo: TransactionInfo = {
- type: TransactionInfoType.TRANSFER,
- sender: mockAddressEx,
- recipient: mockAddressEx,
- direction: TransferDirection.OUTGOING,
- transferInfo: mockTransferInfo,
-}
-
-const defaultTx: TransactionSummary = {
- id: '',
- timestamp: 0,
- txInfo: mockTxInfo,
- txStatus: TransactionStatus.AWAITING_CONFIRMATIONS,
- executionInfo: {
- type: DetailedExecutionInfoType.MULTISIG,
- nonce: 1,
- confirmationsRequired: 2,
- confirmationsSubmitted: 2,
- },
-}
-
-const getMockTx = ({ nonce }: { nonce?: number }): Transaction => {
- return {
- transaction: {
- ...defaultTx,
- executionInfo: {
- ...defaultTx.executionInfo,
- nonce: nonce ?? (defaultTx.executionInfo as MultisigExecutionInfo).nonce,
- } as MultisigExecutionInfo,
- },
- type: TransactionListItemType.TRANSACTION,
- conflictType: ConflictType.NONE,
- }
-}
+import { defaultTx, getMockTx } from '@/tests/mocks/transactions'
describe('getBatchableTransactions', () => {
it('should return an empty array if no transactions are passed', () => {
diff --git a/src/hooks/__tests__/usePreviousNonces.test.ts b/src/hooks/__tests__/usePreviousNonces.test.ts
new file mode 100644
index 0000000000..3115a03cb7
--- /dev/null
+++ b/src/hooks/__tests__/usePreviousNonces.test.ts
@@ -0,0 +1,29 @@
+import { _getUniqueQueuedTxs } from '@/hooks/usePreviousNonces'
+import { getMockTx } from '@/tests/mocks/transactions'
+
+describe('_getUniqueQueuedTxs', () => {
+ it('returns an empty array if input is undefined', () => {
+ const result = _getUniqueQueuedTxs()
+
+ expect(result).toEqual([])
+ })
+
+ it('returns an empty array if input is an empty array', () => {
+ const result = _getUniqueQueuedTxs({ results: [] })
+
+ expect(result).toEqual([])
+ })
+
+ it('only returns one transaction per nonce', () => {
+ const mockTx = getMockTx({ nonce: 0 })
+ const mockTx1 = getMockTx({ nonce: 1 })
+ const mockTx2 = getMockTx({ nonce: 1 })
+
+ const mockPage = {
+ results: [mockTx, mockTx1, mockTx2],
+ }
+ const result = _getUniqueQueuedTxs(mockPage)
+
+ expect(result.length).toEqual(2)
+ })
+})
diff --git a/src/hooks/usePreviousNonces.ts b/src/hooks/usePreviousNonces.ts
new file mode 100644
index 0000000000..ae7ef9e635
--- /dev/null
+++ b/src/hooks/usePreviousNonces.ts
@@ -0,0 +1,31 @@
+import { useMemo } from 'react'
+import { isMultisigExecutionInfo, isTransactionListItem } from '@/utils/transaction-guards'
+import { uniqBy } from 'lodash'
+import useTxQueue from '@/hooks/useTxQueue'
+import { type TransactionListPage } from '@safe-global/safe-gateway-typescript-sdk'
+
+export const _getUniqueQueuedTxs = (page?: TransactionListPage) => {
+ if (!page) {
+ return []
+ }
+
+ const txs = page.results.filter(isTransactionListItem).map((item) => item.transaction)
+
+ return uniqBy(txs, (tx) => {
+ return isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : ''
+ })
+}
+
+const usePreviousNonces = () => {
+ const { page } = useTxQueue()
+
+ const previousNonces = useMemo(() => {
+ return _getUniqueQueuedTxs(page)
+ .map((tx) => (isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : undefined))
+ .filter((nonce): nonce is number => !!nonce)
+ }, [page])
+
+ return previousNonces
+}
+
+export default usePreviousNonces
diff --git a/src/tests/mocks/transactions.ts b/src/tests/mocks/transactions.ts
new file mode 100644
index 0000000000..cc61f95847
--- /dev/null
+++ b/src/tests/mocks/transactions.ts
@@ -0,0 +1,60 @@
+import {
+ type AddressEx,
+ ConflictType,
+ DetailedExecutionInfoType,
+ type MultisigExecutionInfo,
+ type Transaction,
+ type TransactionInfo,
+ TransactionInfoType,
+ TransactionListItemType,
+ TransactionStatus,
+ type TransactionSummary,
+ TransactionTokenType,
+ TransferDirection,
+ type TransferInfo,
+} from '@safe-global/safe-gateway-typescript-sdk'
+
+const mockAddressEx: AddressEx = {
+ value: 'string',
+}
+
+const mockTransferInfo: TransferInfo = {
+ type: TransactionTokenType.ERC20,
+ tokenAddress: 'string',
+ value: 'string',
+}
+
+const mockTxInfo: TransactionInfo = {
+ type: TransactionInfoType.TRANSFER,
+ sender: mockAddressEx,
+ recipient: mockAddressEx,
+ direction: TransferDirection.OUTGOING,
+ transferInfo: mockTransferInfo,
+}
+
+export const defaultTx: TransactionSummary = {
+ id: '',
+ timestamp: 0,
+ txInfo: mockTxInfo,
+ txStatus: TransactionStatus.AWAITING_CONFIRMATIONS,
+ executionInfo: {
+ type: DetailedExecutionInfoType.MULTISIG,
+ nonce: 1,
+ confirmationsRequired: 2,
+ confirmationsSubmitted: 2,
+ },
+}
+
+export const getMockTx = ({ nonce }: { nonce?: number }): Transaction => {
+ return {
+ transaction: {
+ ...defaultTx,
+ executionInfo: {
+ ...defaultTx.executionInfo,
+ nonce: nonce ?? (defaultTx.executionInfo as MultisigExecutionInfo).nonce,
+ } as MultisigExecutionInfo,
+ },
+ type: TransactionListItemType.TRANSACTION,
+ conflictType: ConflictType.NONE,
+ }
+}