diff --git a/src/components/recovery/RecoveryContext/__tests__/useRecoveryPendingTxs.test.ts b/src/components/recovery/RecoveryContext/__tests__/useRecoveryPendingTxs.test.ts index 839e335907..441764f7c3 100644 --- a/src/components/recovery/RecoveryContext/__tests__/useRecoveryPendingTxs.test.ts +++ b/src/components/recovery/RecoveryContext/__tests__/useRecoveryPendingTxs.test.ts @@ -9,6 +9,7 @@ describe('useRecoveryPendingTxs', () => { const delayModifierAddress = faker.finance.ethereumAddress() const txHash = faker.string.hexadecimal() const recoveryTxHash = faker.string.hexadecimal() + const txType = faker.helpers.enumValue(RecoveryTxType) const { result } = renderHook(() => useRecoveryPendingTxs()) expect(result.current).toStrictEqual({}) @@ -18,12 +19,12 @@ describe('useRecoveryPendingTxs', () => { moduleAddress: delayModifierAddress, txHash, recoveryTxHash, - txType: faker.helpers.enumValue(RecoveryTxType), + txType, }) }) expect(result.current).toStrictEqual({ - [recoveryTxHash]: RecoveryEvent.PROCESSING, + [recoveryTxHash]: { status: RecoveryEvent.PROCESSING, txType }, }) }) @@ -80,7 +81,7 @@ describe('useRecoveryPendingTxs', () => { }) expect(result.current).toStrictEqual({ - [recoveryTxHash]: RecoveryEvent.PROCESSED, + [recoveryTxHash]: { status: RecoveryEvent.PROCESSED, txType }, }) }) diff --git a/src/components/recovery/RecoveryContext/__tests__/useRecoverySuccessEvents.test.ts b/src/components/recovery/RecoveryContext/__tests__/useRecoverySuccessEvents.test.ts new file mode 100644 index 0000000000..303dfa0a2a --- /dev/null +++ b/src/components/recovery/RecoveryContext/__tests__/useRecoverySuccessEvents.test.ts @@ -0,0 +1,126 @@ +import { faker } from '@faker-js/faker' + +import { renderHook } from '@/tests/test-utils' +import { useRecoverySuccessEvents } from '../useRecoverySuccessEvents' +import { recoveryDispatch, RecoveryEvent, RecoveryTxType } from '@/services/recovery/recoveryEvents' + +jest.mock('@/services/recovery/recoveryEvents', () => ({ + ...jest.requireActual('@/services/recovery/recoveryEvents'), + recoveryDispatch: jest.fn(), +})) + +const mockRecoveryDispatch = recoveryDispatch as jest.MockedFunction + +describe('useRecoverySuccessEvents', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + it('should not dispatch SUCCESS event if recoveryState is not defined', () => { + const pending = { + [faker.string.hexadecimal()]: {}, + } + + const { result } = renderHook(() => useRecoverySuccessEvents(pending as any)) + + expect(result.current).toBeUndefined() + + expect(mockRecoveryDispatch).not.toHaveBeenCalled() + }) + + it('should not dispatch SUCCESS event if recoveryState is empty', () => { + const pending = { + [faker.string.hexadecimal()]: {}, + } + const recoveryState = [] as any[] + + const { result } = renderHook(() => useRecoverySuccessEvents(pending as any, recoveryState)) + + expect(result.current).toBeUndefined() + + expect(mockRecoveryDispatch).not.toHaveBeenCalled() + }) + + it('should not dispatch SUCCESS event if pending is empty', () => { + const pending = {} + const recoveryState = [{ queue: [] }] + + const { result } = renderHook(() => useRecoverySuccessEvents(pending, recoveryState as any)) + + expect(result.current).toBeUndefined() + + expect(mockRecoveryDispatch).not.toHaveBeenCalled() + }) + + it('should not dispatch SUCCESS event if pending is not PROCESSED', () => { + const pending = { + [faker.string.hexadecimal()]: { + status: faker.helpers.arrayElement([RecoveryEvent.PROCESSING, RecoveryEvent.FAILED]), + }, + } + const recoveryState = [{ queue: [] }] + + renderHook(() => useRecoverySuccessEvents(pending as any, recoveryState as any)) + + expect(mockRecoveryDispatch).not.toHaveBeenCalled() + }) + + it('should dispatch SUCCESS event if pending is PROCESSED and txType is PROPOSAL', () => { + const pending = { + [faker.string.hexadecimal()]: { + status: RecoveryEvent.PROCESSED, + txType: RecoveryTxType.PROPOSAL, + }, + } + const recoveryState = [{ queue: [] }] + + renderHook(() => useRecoverySuccessEvents(pending as any, recoveryState as any)) + + expect(mockRecoveryDispatch).toHaveBeenCalledWith(RecoveryEvent.SUCCESS, { + recoveryTxHash: expect.any(String), + txType: RecoveryTxType.PROPOSAL, + }) + }) + + it('should not dispatch SUCCESS event if pending is PROCESSED and txType is not PROPOSAL and there is a queue', () => { + const recoveryTxHash = faker.string.hexadecimal() + const pending = { + [recoveryTxHash]: { + status: RecoveryEvent.PROCESSED, + txType: faker.helpers.arrayElement([RecoveryTxType.EXECUTION, RecoveryTxType.SKIP_EXPIRED]), + }, + } + const recoveryState = [ + { + queue: [ + { + args: { + txHash: recoveryTxHash, + }, + }, + ], + }, + ] + + renderHook(() => useRecoverySuccessEvents(pending as any, recoveryState as any)) + + expect(mockRecoveryDispatch).not.toHaveBeenCalled() + }) + + it('should dispatch SUCCESS event if pending is PROCESSED and pending transaction is not queued', () => { + const pending = { + [faker.string.hexadecimal()]: { + status: RecoveryEvent.PROCESSED, + txType: RecoveryTxType.PROPOSAL, + }, + } + const recoveryState = [{ queue: [] }] + + renderHook(() => useRecoverySuccessEvents(pending as any, recoveryState as any)) + + expect(mockRecoveryDispatch).toHaveBeenCalledWith(RecoveryEvent.SUCCESS, { + recoveryTxHash: expect.any(String), + txType: RecoveryTxType.PROPOSAL, + }) + }) +}) diff --git a/src/components/recovery/RecoveryContext/index.tsx b/src/components/recovery/RecoveryContext/index.tsx index eae93892e5..6061274f0a 100644 --- a/src/components/recovery/RecoveryContext/index.tsx +++ b/src/components/recovery/RecoveryContext/index.tsx @@ -4,6 +4,7 @@ import type { ReactElement, ReactNode } from 'react' import { useRecoveryState } from './useRecoveryState' import { useRecoveryDelayModifiers } from './useRecoveryDelayModifiers' import { useRecoveryPendingTxs } from './useRecoveryPendingTxs' +import { useRecoverySuccessEvents } from './useRecoverySuccessEvents' import type { AsyncResult } from '@/hooks/useAsync' import type { RecoveryState } from '@/services/recovery/recovery-state' @@ -21,6 +22,8 @@ export function RecoveryProvider({ children }: { children: ReactNode }): ReactEl const [recoveryState, recoveryStateError, recoveryStateLoading] = useRecoveryState(delayModifiers) const pending = useRecoveryPendingTxs() + useRecoverySuccessEvents(pending, recoveryState) + const data = recoveryState const error = delayModifiersError || recoveryStateError const loading = delayModifiersLoading || recoveryStateLoading diff --git a/src/components/recovery/RecoveryContext/useRecoveryPendingTxs.ts b/src/components/recovery/RecoveryContext/useRecoveryPendingTxs.ts index 07552b1422..ca77a225b5 100644 --- a/src/components/recovery/RecoveryContext/useRecoveryPendingTxs.ts +++ b/src/components/recovery/RecoveryContext/useRecoveryPendingTxs.ts @@ -1,18 +1,25 @@ import { useEffect, useState } from 'react' import { RecoveryEvent, recoverySubscribe } from '@/services/recovery/recoveryEvents' +import type { RecoveryTxType } from '@/services/recovery/recoveryEvents' -export type PendingRecoveryTransactions = { [recoveryTxHash: string]: RecoveryEvent } +type PendingRecoveryTransactions = { + [recoveryTxHash: string]: { + status: RecoveryEvent + txType: RecoveryTxType + } +} const pendingStatuses: { [key in RecoveryEvent]: RecoveryEvent | null } = { [RecoveryEvent.PROCESSING_BY_SMART_CONTRACT_WALLET]: null, [RecoveryEvent.PROCESSING]: RecoveryEvent.PROCESSING, [RecoveryEvent.PROCESSED]: RecoveryEvent.PROCESSED, + [RecoveryEvent.SUCCESS]: null, [RecoveryEvent.REVERTED]: null, [RecoveryEvent.FAILED]: null, } -export function useRecoveryPendingTxs(): PendingRecoveryTransactions { +export function useRecoveryPendingTxs() { const [pending, setPending] = useState({}) useEffect(() => { @@ -30,7 +37,13 @@ export function useRecoveryPendingTxs(): PendingRecoveryTransactions { return rest } - return { ...prev, [recoveryTxHash]: status } + return { + ...prev, + [recoveryTxHash]: { + txType: detail.txType, + status, + }, + } }) }), ) diff --git a/src/components/recovery/RecoveryContext/useRecoverySuccessEvents.ts b/src/components/recovery/RecoveryContext/useRecoverySuccessEvents.ts new file mode 100644 index 0000000000..581bd078fc --- /dev/null +++ b/src/components/recovery/RecoveryContext/useRecoverySuccessEvents.ts @@ -0,0 +1,43 @@ +import { useEffect } from 'react' + +import { RecoveryEvent, RecoveryTxType, recoveryDispatch } from '@/services/recovery/recoveryEvents' +import type { RecoveryState } from '@/services/recovery/recovery-state' +import type { useRecoveryPendingTxs } from './useRecoveryPendingTxs' + +export function useRecoverySuccessEvents( + pending: ReturnType, + recoveryState?: RecoveryState, +): void { + useEffect(() => { + const pendingEntries = Object.entries(pending) + + if (!recoveryState || recoveryState.length === 0 || pendingEntries.length === 0) { + return + } + + pendingEntries.forEach(([recoveryTxHash, { txType, status }]) => { + // Transaction successfully executed, waiting for recovery state to be loaded again + if (status !== RecoveryEvent.PROCESSED) { + return + } + + const isQueued = recoveryState.some(({ queue }) => queue.some(({ args }) => args.txHash === recoveryTxHash)) + + if (isQueued) { + // Only proposals should appear in the queue + if (txType === RecoveryTxType.PROPOSAL) { + recoveryDispatch(RecoveryEvent.SUCCESS, { + recoveryTxHash, + txType, + }) + } + } else { + // Executions/cancellations are removed from the queue + recoveryDispatch(RecoveryEvent.SUCCESS, { + recoveryTxHash, + txType, + }) + } + }) + }, [pending, recoveryState]) +} diff --git a/src/components/recovery/RecoveryStatus/index.tsx b/src/components/recovery/RecoveryStatus/index.tsx index 4215dc326e..a4c5878bb4 100644 --- a/src/components/recovery/RecoveryStatus/index.tsx +++ b/src/components/recovery/RecoveryStatus/index.tsx @@ -17,7 +17,7 @@ export const RecoveryStatus = ({ recovery }: { recovery: RecoveryQueueItem }): R const { isExecutable, isExpired } = useRecoveryTxState(recovery) const { pending } = useContext(RecoveryContext) - const pendingTxStatus = pending?.[recovery.args.txHash] + const pendingTxStatus = pending?.[recovery.args.txHash]?.status const status = pendingTxStatus ? ( <> diff --git a/src/hooks/useRecoveryTxNotification.ts b/src/hooks/useRecoveryTxNotification.ts index ab2c4c8906..c35bbeefce 100644 --- a/src/hooks/useRecoveryTxNotification.ts +++ b/src/hooks/useRecoveryTxNotification.ts @@ -8,13 +8,19 @@ import { RecoveryEvent, RecoveryTxType, recoverySubscribe } from '@/services/rec import { getExplorerLink } from '@/utils/gateway' import { useCurrentChain } from './useChains' +const SUCCESS_EVENTS = [ + RecoveryEvent.PROCESSING_BY_SMART_CONTRACT_WALLET, + RecoveryEvent.PROCESSED, + RecoveryEvent.SUCCESS, +] + const RecoveryTxNotifications = { + [RecoveryEvent.PROCESSING_BY_SMART_CONTRACT_WALLET]: 'Confirm the execution in your wallet.', [RecoveryEvent.PROCESSING]: 'Validating...', [RecoveryEvent.PROCESSED]: 'Successfully validated. Loading...', [RecoveryEvent.REVERTED]: 'Reverted. Please check your gas settings.', [RecoveryEvent.FAILED]: 'Failed.', - // TODO: Add success event - // [RecoveryEvent.SUCCESS]: 'Successfully executed.', + [RecoveryEvent.SUCCESS]: 'Successfully executed.', } const RecoveryTxNotificationTitles = { @@ -37,9 +43,11 @@ export function useRecoveryTxNotifications(): void { return } - const unsubFns = Object.entries(RecoveryTxNotifications).map(([event, notification]) => - recoverySubscribe(event as RecoveryEvent, async (detail) => { - const isSuccess = event === RecoveryEvent.PROCESSED + const entries = Object.entries(RecoveryTxNotifications) as Array<[keyof typeof RecoveryTxNotifications, string]> + + const unsubFns = entries.map(([event, notification]) => + recoverySubscribe(event, async (detail) => { + const isSuccess = SUCCESS_EVENTS.includes(event) const isError = 'error' in detail const txHash = 'txHash' in detail ? detail.txHash : undefined diff --git a/src/services/recovery/recoveryEvents.ts b/src/services/recovery/recoveryEvents.ts index 990f7ade3b..7870c81121 100644 --- a/src/services/recovery/recoveryEvents.ts +++ b/src/services/recovery/recoveryEvents.ts @@ -2,10 +2,11 @@ import EventBus from '../EventBus' export enum RecoveryEvent { PROCESSING_BY_SMART_CONTRACT_WALLET = 'PROCESSING_BY_SMART_CONTRACT_WALLET', - PROCESSING = 'PROCESSING', - REVERTED = 'REVERTED', - PROCESSED = 'PROCESSED', + PROCESSING = 'PROCESSING', // Submitted to the blockchain + PROCESSED = 'PROCESSED', // Executed on the blockchain + SUCCESS = 'SUCCESS', // Loaded from the blockchain FAILED = 'FAILED', + REVERTED = 'REVERTED', } export enum RecoveryTxType { @@ -47,6 +48,10 @@ export interface RecoveryEvents { error: Error txType: RecoveryTxType } + [RecoveryEvent.SUCCESS]: { + recoveryTxHash: string + txType: RecoveryTxType + } } const recoveryEventBus = new EventBus()