diff --git a/src/components/common/PaginatedTxns/index.tsx b/src/components/common/PaginatedTxns/index.tsx
index cef80dd98b..70439014e8 100644
--- a/src/components/common/PaginatedTxns/index.tsx
+++ b/src/components/common/PaginatedTxns/index.tsx
@@ -13,6 +13,7 @@ import { isTransactionListItem } from '@/utils/transaction-guards'
import NoTransactionsIcon from '@/public/images/transactions/no-transactions.svg'
import { useHasPendingTxs } from '@/hooks/usePendingTxs'
import useSafeInfo from '@/hooks/useSafeInfo'
+import { useRecoveryQueue } from '@/hooks/useRecoveryQueue'
const NoQueuedTxns = () => {
return } text="Queued transactions will appear here" />
@@ -38,6 +39,7 @@ const TxPage = ({
const { page, error, loading } = useTxns(pageUrl)
const [filter] = useTxFilter()
const isQueue = useTxns === useTxQueue
+ const recoveryQueue = useRecoveryQueue()
const hasPending = useHasPendingTxs()
return (
@@ -50,7 +52,7 @@ const TxPage = ({
{page && page.results.length > 0 && }
- {isQueue && page?.results.length === 0 && !hasPending && }
+ {isQueue && page?.results.length === 0 && recoveryQueue.length === 0 && !hasPending && }
{error && Error loading transactions}
diff --git a/src/components/dashboard/PendingTxs/PendingTxsList.tsx b/src/components/dashboard/PendingTxs/PendingTxsList.tsx
index edf0b2558c..de683af2eb 100644
--- a/src/components/dashboard/PendingTxs/PendingTxsList.tsx
+++ b/src/components/dashboard/PendingTxs/PendingTxsList.tsx
@@ -9,7 +9,7 @@ import useTxQueue from '@/hooks/useTxQueue'
import { AppRoutes } from '@/config/routes'
import NoTransactionsIcon from '@/public/images/transactions/no-transactions.svg'
import css from './styles.module.css'
-import { isSignableBy, isExecutable } from '@/utils/transaction-guards'
+import { isSignableBy, isExecutable, isRecoveryQueueItem } from '@/utils/transaction-guards'
import useWallet from '@/hooks/wallets/useWallet'
import useSafeInfo from '@/hooks/useSafeInfo'
import { useRecoveryQueue } from '@/hooks/useRecoveryQueue'
@@ -73,10 +73,6 @@ export function _getTransactionsToDisplay({
return [...recoveryQueue, ...queueToDisplay]
}
-function isRecoveryQueueItem(tx: Transaction | RecoveryQueueItem): tx is RecoveryQueueItem {
- return 'args' in tx
-}
-
const PendingTxsList = (): ReactElement | null => {
const router = useRouter()
const { page, loading } = useTxQueue()
diff --git a/src/components/recovery/GroupedRecoveryListItems/index.tsx b/src/components/recovery/GroupedRecoveryListItems/index.tsx
new file mode 100644
index 0000000000..dafbba1bd2
--- /dev/null
+++ b/src/components/recovery/GroupedRecoveryListItems/index.tsx
@@ -0,0 +1,56 @@
+import { Box, Paper, Typography } from '@mui/material'
+import { partition } from 'lodash'
+import type { RecoveryQueueItem } from '@/services/recovery/recovery-state'
+import type { Transaction } from '@safe-global/safe-gateway-typescript-sdk'
+import type { ReactElement } from 'react'
+
+import { isRecoveryQueueItem } from '@/utils/transaction-guards'
+import ExpandableTransactionItem from '@/components/transactions/TxListItem/ExpandableTransactionItem'
+import { RecoveryListItem } from '../RecoveryListItem'
+import ExternalLink from '@/components/common/ExternalLink'
+
+import css from '@/components/transactions/GroupedTxListItems/styles.module.css'
+
+function Disclaimer({ isMalicious }: { isMalicious: boolean }): ReactElement {
+ return (
+ `${palette.warning.background} !important` }}
+ >
+
+
+ Cancelling {isMalicious ? 'malicious transaction' : 'Account recovery'}.
+ {' '}
+ You will need to execute the cancellation.{' '}
+
+ Learn more
+
+
+
+ )
+}
+
+export function GroupedRecoveryListItems({ items }: { items: Array }): ReactElement {
+ const [recoveries, cancellations] = partition(items, isRecoveryQueueItem)
+
+ // Should only be one recovery item but check array in case
+ const isMalicious = recoveries.some((recovery) => recovery.isMalicious)
+
+ return (
+ palette.warning.light }}>
+
+
+ {cancellations.map((tx) => (
+
+ ))}
+
+ {recoveries.map((recovery) => (
+
+ ))}
+
+ )
+}
diff --git a/src/components/recovery/RecoveryList/index.tsx b/src/components/recovery/RecoveryList/index.tsx
index 8430286a4b..6097203a7c 100644
--- a/src/components/recovery/RecoveryList/index.tsx
+++ b/src/components/recovery/RecoveryList/index.tsx
@@ -1,15 +1,21 @@
+import { useMemo } from 'react'
import type { ReactElement } from 'react'
import { TxListGrid } from '@/components/transactions/TxList'
import { RecoveryListItem } from '@/components/recovery/RecoveryListItem'
import { useRecoveryQueue } from '@/hooks/useRecoveryQueue'
+import { groupRecoveryTransactions } from '@/utils/tx-list'
+import useTxQueue from '@/hooks/useTxQueue'
+import { GroupedRecoveryListItems } from '../GroupedRecoveryListItems'
+import { isRecoveryQueueItem } from '@/utils/transaction-guards'
+import type { RecoveryQueueItem } from '@/services/recovery/recovery-state'
import labelCss from '@/components/transactions/GroupLabel/styles.module.css'
export function RecoveryList(): ReactElement | null {
- const queue = useRecoveryQueue()
+ const recoveryQueue = useRecoveryQueue()
- if (queue.length === 0) {
+ if (recoveryQueue.length === 0) {
return null
}
@@ -18,10 +24,37 @@ export function RecoveryList(): ReactElement | null {
Pending recovery
- {queue.map((item) => (
-
- ))}
+ <_RecoveryList recoveryQueue={recoveryQueue} />
>
)
}
+
+// Conditional hook
+function _RecoveryList({ recoveryQueue }: { recoveryQueue: Array }): ReactElement {
+ const queue = useTxQueue()
+
+ const groupedItems = useMemo(() => {
+ if (!queue?.page?.results || queue.page.results.length === 0) {
+ return recoveryQueue
+ }
+ return groupRecoveryTransactions(queue.page.results, recoveryQueue)
+ }, [queue, recoveryQueue])
+
+ const transactions = useMemo(() => {
+ return groupedItems.map((item, index) => {
+ if (Array.isArray(item)) {
+ return
+ }
+
+ if (isRecoveryQueueItem(item)) {
+ return
+ }
+
+ // Will never have non-recovery transactions here
+ return null
+ })
+ }, [groupedItems])
+
+ return {transactions}
+}
diff --git a/src/utils/__tests__/tx-list.test.ts b/src/utils/__tests__/tx-list.test.ts
index bead5fa51d..d8782c7fd8 100644
--- a/src/utils/__tests__/tx-list.test.ts
+++ b/src/utils/__tests__/tx-list.test.ts
@@ -1,6 +1,8 @@
+import { faker } from '@faker-js/faker'
+import { TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk'
import type { TransactionListItem } from '@safe-global/safe-gateway-typescript-sdk'
-import { groupConflictingTxs } from '@/utils/tx-list'
+import { groupConflictingTxs, groupRecoveryTransactions, _getRecoveryCancellations } from '@/utils/tx-list'
describe('tx-list', () => {
describe('groupConflictingTxs', () => {
@@ -110,4 +112,123 @@ describe('tx-list', () => {
expect(result).toEqual(list)
})
})
+
+ describe('getRecoveryCancellations', () => {
+ it('should return cancellation transactions', () => {
+ const moduleAddress = faker.finance.ethereumAddress()
+
+ const transactions = [
+ {
+ transaction: {
+ txInfo: {
+ type: TransactionInfoType.CUSTOM,
+ to: {
+ value: moduleAddress,
+ },
+ methodName: 'enableModule',
+ },
+ },
+ },
+ {
+ transaction: {
+ txInfo: {
+ type: TransactionInfoType.TRANSFER,
+ },
+ },
+ },
+ {
+ transaction: {
+ txInfo: {
+ type: TransactionInfoType.CUSTOM,
+ to: {
+ value: moduleAddress,
+ },
+ methodName: 'setTxNonce',
+ },
+ },
+ },
+ ]
+
+ expect(_getRecoveryCancellations(moduleAddress, transactions as any)).toEqual([
+ {
+ transaction: {
+ txInfo: {
+ type: TransactionInfoType.CUSTOM,
+ to: {
+ value: moduleAddress,
+ },
+ methodName: 'setTxNonce',
+ },
+ },
+ },
+ ])
+ })
+ })
+
+ describe('groupRecoveryTransactions', () => {
+ it.only('should group recovery transactions with their cancellations', () => {
+ const moduleAddress = faker.finance.ethereumAddress()
+
+ const queue = [
+ {
+ type: 'TRANSACTION',
+ transaction: {
+ txInfo: {
+ type: TransactionInfoType.CUSTOM,
+ to: {
+ value: moduleAddress,
+ },
+ methodName: 'enableModule',
+ },
+ },
+ },
+ {
+ type: 'TRANSACTION',
+ transaction: {
+ txInfo: {
+ type: TransactionInfoType.TRANSFER,
+ },
+ },
+ },
+ {
+ type: 'TRANSACTION',
+ transaction: {
+ txInfo: {
+ type: TransactionInfoType.CUSTOM,
+ to: {
+ value: moduleAddress,
+ },
+ methodName: 'setTxNonce',
+ },
+ },
+ },
+ ]
+
+ const recoveryQueue = [
+ {
+ address: moduleAddress,
+ },
+ ]
+
+ expect(groupRecoveryTransactions(queue as any, recoveryQueue as any)).toEqual([
+ [
+ {
+ address: moduleAddress,
+ },
+ {
+ type: 'TRANSACTION',
+ transaction: {
+ txInfo: {
+ type: TransactionInfoType.CUSTOM,
+ to: {
+ value: moduleAddress,
+ },
+ methodName: 'setTxNonce',
+ },
+ },
+ },
+ ],
+ ])
+ })
+ })
})
diff --git a/src/utils/transaction-guards.ts b/src/utils/transaction-guards.ts
index a8f402a8b6..3131aa430a 100644
--- a/src/utils/transaction-guards.ts
+++ b/src/utils/transaction-guards.ts
@@ -36,6 +36,7 @@ import {
import { getSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts'
import { sameAddress } from '@/utils/addresses'
import type { NamedAddress } from '@/components/new-safe/create/types'
+import type { RecoveryQueueItem } from '@/services/recovery/recovery-state'
export const isTxQueued = (value: TransactionStatus): boolean => {
return [TransactionStatus.AWAITING_CONFIRMATIONS, TransactionStatus.AWAITING_EXECUTION].includes(value)
@@ -110,6 +111,10 @@ export const isTransactionListItem = (value: TransactionListItem): value is Tran
return value.type === TransactionListItemType.TRANSACTION
}
+export function isRecoveryQueueItem(value: TransactionListItem | RecoveryQueueItem): value is RecoveryQueueItem {
+ return 'args' in value
+}
+
// Narrows `Transaction`
export const isMultisigExecutionInfo = (value?: ExecutionInfo): value is MultisigExecutionInfo =>
value?.type === DetailedExecutionInfoType.MULTISIG
diff --git a/src/utils/tx-list.ts b/src/utils/tx-list.ts
index 767108451e..94a6e01f55 100644
--- a/src/utils/tx-list.ts
+++ b/src/utils/tx-list.ts
@@ -1,5 +1,9 @@
+import { TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk'
import type { Transaction, TransactionListItem } from '@safe-global/safe-gateway-typescript-sdk'
+
import { isConflictHeaderListItem, isNoneConflictType, isTransactionListItem } from '@/utils/transaction-guards'
+import { sameAddress } from './addresses'
+import type { RecoveryQueueItem } from '@/services/recovery/recovery-state'
type GroupedTxs = Array
@@ -29,6 +33,36 @@ export const groupConflictingTxs = (list: TransactionListItem[]): GroupedTxs =>
})
}
+export function _getRecoveryCancellations(moduleAddress: string, transactions: Array) {
+ const CANCELLATION_TX_METHOD_NAME = 'setTxNonce'
+
+ return transactions.filter(({ transaction }) => {
+ const { txInfo } = transaction
+ return (
+ txInfo.type === TransactionInfoType.CUSTOM &&
+ sameAddress(txInfo.to.value, moduleAddress) &&
+ txInfo.methodName === CANCELLATION_TX_METHOD_NAME
+ )
+ })
+}
+
+export function groupRecoveryTransactions(queue: Array, recoveryQueue: Array) {
+ const transactions = queue.filter(isTransactionListItem)
+
+ return recoveryQueue.reduce>>((acc, item) => {
+ acc.push([item])
+
+ const cancellations = _getRecoveryCancellations(item.address, transactions)
+
+ if (cancellations.length > 0) {
+ const prevItem = acc[acc.length - 1]
+ prevItem.push(...cancellations)
+ }
+
+ return acc
+ }, [])
+}
+
export const getLatestTransactions = (list: TransactionListItem[] = []): Transaction[] => {
return (
groupConflictingTxs(list)