Skip to content

Commit

Permalink
feat: recovery attempt + cancellation grouping (#2869)
Browse files Browse the repository at this point in the history
* feat: recovery attempt + cancellation grouping

* fix: test

* fix: typo

* fix: remove unnecessary change for test

* fix: "Rejecting" -> "Cancelling"

* fix: text
  • Loading branch information
iamacook committed Nov 23, 2023
1 parent 0b299bc commit 76f0031
Show file tree
Hide file tree
Showing 7 changed files with 259 additions and 12 deletions.
4 changes: 3 additions & 1 deletion src/components/common/PaginatedTxns/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PagePlaceholder img={<NoTransactionsIcon />} text="Queued transactions will appear here" />
Expand All @@ -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 (
Expand All @@ -50,7 +52,7 @@ const TxPage = ({

{page && page.results.length > 0 && <TxList items={page.results} />}

{isQueue && page?.results.length === 0 && !hasPending && <NoQueuedTxns />}
{isQueue && page?.results.length === 0 && recoveryQueue.length === 0 && !hasPending && <NoQueuedTxns />}

{error && <ErrorMessage>Error loading transactions</ErrorMessage>}

Expand Down
6 changes: 1 addition & 5 deletions src/components/dashboard/PendingTxs/PendingTxsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down
56 changes: 56 additions & 0 deletions src/components/recovery/GroupedRecoveryListItems/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box
className={css.disclaimerContainer}
sx={{ bgcolor: ({ palette }) => `${palette.warning.background} !important` }}
>
<Typography>
<Typography component="span" fontWeight={700}>
Cancelling {isMalicious ? 'malicious transaction' : 'Account recovery'}.
</Typography>{' '}
You will need to execute the cancellation.{' '}
<ExternalLink
// TODO: Add link
href="#"
title="Learn more about Account recovery"
>
Learn more
</ExternalLink>
</Typography>
</Box>
)
}

export function GroupedRecoveryListItems({ items }: { items: Array<Transaction | RecoveryQueueItem> }): 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 (
<Paper className={css.container} variant="outlined" sx={{ borderColor: ({ palette }) => palette.warning.light }}>
<Disclaimer isMalicious={isMalicious} />

{cancellations.map((tx) => (
<ExpandableTransactionItem key={tx.transaction.id} item={tx} />
))}

{recoveries.map((recovery) => (
<RecoveryListItem key={recovery.transactionHash} item={recovery} />
))}
</Paper>
)
}
43 changes: 38 additions & 5 deletions src/components/recovery/RecoveryList/index.tsx
Original file line number Diff line number Diff line change
@@ -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
}

Expand All @@ -18,10 +24,37 @@ export function RecoveryList(): ReactElement | null {
<div className={labelCss.container}>Pending recovery</div>

<TxListGrid>
{queue.map((item) => (
<RecoveryListItem item={item} key={item.transactionHash} />
))}
<_RecoveryList recoveryQueue={recoveryQueue} />
</TxListGrid>
</>
)
}

// Conditional hook
function _RecoveryList({ recoveryQueue }: { recoveryQueue: Array<RecoveryQueueItem> }): 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 <GroupedRecoveryListItems items={item} key={index} />
}

if (isRecoveryQueueItem(item)) {
return <RecoveryListItem item={item} key={item.transactionHash} />
}

// Will never have non-recovery transactions here
return null
})
}, [groupedItems])

return <TxListGrid>{transactions}</TxListGrid>
}
123 changes: 122 additions & 1 deletion src/utils/__tests__/tx-list.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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',
},
},
},
],
])
})
})
})
5 changes: 5 additions & 0 deletions src/utils/transaction-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions src/utils/tx-list.ts
Original file line number Diff line number Diff line change
@@ -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<TransactionListItem | Transaction[]>

Expand Down Expand Up @@ -29,6 +33,36 @@ export const groupConflictingTxs = (list: TransactionListItem[]): GroupedTxs =>
})
}

export function _getRecoveryCancellations(moduleAddress: string, transactions: Array<Transaction>) {
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<TransactionListItem>, recoveryQueue: Array<RecoveryQueueItem>) {
const transactions = queue.filter(isTransactionListItem)

return recoveryQueue.reduce<Array<Array<Transaction | RecoveryQueueItem>>>((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)
Expand Down

0 comments on commit 76f0031

Please sign in to comment.