Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: recovery attempt + cancellation grouping #2869

Merged
merged 6 commits into from
Nov 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading