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)