From ba80fda9b9a84b2e354b635437c44a021f50c8df Mon Sep 17 00:00:00 2001 From: iamacook Date: Tue, 28 Nov 2023 16:01:40 +0100 Subject: [PATCH] feat: recovery transaction processing state --- .../recovery/CancelRecoveryButton/index.tsx | 4 +- .../recovery/ExecuteRecoveryButton/index.tsx | 12 +- .../recovery/RecoveryContext/index.tsx | 15 +- .../recovery/RecoveryStatus/index.tsx | 6 +- .../__tests__/useRecoveryTxState.test.tsx | 130 +++++++++++++----- src/hooks/useRecoveryTxState.ts | 13 +- 6 files changed, 136 insertions(+), 44 deletions(-) diff --git a/src/components/recovery/CancelRecoveryButton/index.tsx b/src/components/recovery/CancelRecoveryButton/index.tsx index 4123b300e0..e922d57d19 100644 --- a/src/components/recovery/CancelRecoveryButton/index.tsx +++ b/src/components/recovery/CancelRecoveryButton/index.tsx @@ -29,7 +29,7 @@ export function CancelRecoveryButton({ const { setSubmitError } = useContext(RecoveryListItemContext) const isOwner = useIsSafeOwner() const isGuardian = useIsGuardian() - const { isExpired } = useRecoveryTxState(recovery) + const { isExpired, isPending } = useRecoveryTxState(recovery) const { setTxFlow } = useContext(TxModalContext) const onboard = useOnboard() const { safe } = useSafeInfo() @@ -61,7 +61,7 @@ export function CancelRecoveryButton({ return ( {(isOk) => { - const isDisabled = !isOk || (isGuardian && !isExpired) + const isDisabled = !isOk || isPending || (isGuardian && !isExpired) return compact ? ( diff --git a/src/components/recovery/ExecuteRecoveryButton/index.tsx b/src/components/recovery/ExecuteRecoveryButton/index.tsx index 9633d94057..610d1a49cc 100644 --- a/src/components/recovery/ExecuteRecoveryButton/index.tsx +++ b/src/components/recovery/ExecuteRecoveryButton/index.tsx @@ -23,7 +23,8 @@ export function ExecuteRecoveryButton({ compact?: boolean }): ReactElement { const { setSubmitError } = useContext(RecoveryListItemContext) - const { isExecutable } = useRecoveryTxState(recovery) + const { isExecutable, isPending } = useRecoveryTxState(recovery) + const { setPending } = useContext(RecoveryContext) const onboard = useOnboard() const { safe } = useSafeInfo() const { refetch } = useContext(RecoveryContext) @@ -36,6 +37,8 @@ export function ExecuteRecoveryButton({ return } + setPending((prev) => ({ ...prev, [recovery.transactionHash]: true })) + try { await dispatchRecoveryExecution({ onboard, @@ -49,13 +52,18 @@ export function ExecuteRecoveryButton({ trackError(Errors._812, e) setSubmitError(err) + } finally { + setPending((prev) => { + const { [recovery.transactionHash]: _, ...rest } = prev + return rest + }) } } return ( {(isOk) => { - const isDisabled = !isOk || !isExecutable + const isDisabled = !isOk || !isExecutable || isPending return ( diff --git a/src/components/recovery/RecoveryContext/index.tsx b/src/components/recovery/RecoveryContext/index.tsx index bf39247da6..1b52c5c41c 100644 --- a/src/components/recovery/RecoveryContext/index.tsx +++ b/src/components/recovery/RecoveryContext/index.tsx @@ -1,18 +1,24 @@ -import { createContext, useContext } from 'react' -import type { ReactElement, ReactNode } from 'react' +import { createContext, useContext, useState } from 'react' +import type { ReactElement, ReactNode, Dispatch, SetStateAction } from 'react' import { useRecoveryState } from './useRecoveryState' import { useRecoveryDelayModifiers } from './useRecoveryDelayModifiers' import type { AsyncResult } from '@/hooks/useAsync' import type { RecoveryState } from '@/services/recovery/recovery-state' +type PendingRecoveryTransactions = { [txHash: string]: boolean } + // State of current Safe, populated on load export const RecoveryContext = createContext<{ state: AsyncResult refetch: () => void + pending: PendingRecoveryTransactions + setPending: Dispatch> }>({ state: [undefined, undefined, false], refetch: () => {}, + pending: {}, + setPending: () => {}, }) export function RecoveryProvider({ children }: { children: ReactNode }): ReactElement { @@ -21,13 +27,16 @@ export function RecoveryProvider({ children }: { children: ReactNode }): ReactEl data: [recoveryState, recoveryStateError, recoveryStateLoading], refetch, } = useRecoveryState(delayModifiers) + const [pending, setPending] = useState({}) const data = recoveryState const error = delayModifiersError || recoveryStateError const loading = delayModifiersLoading || recoveryStateLoading return ( - {children} + + {children} + ) } diff --git a/src/components/recovery/RecoveryStatus/index.tsx b/src/components/recovery/RecoveryStatus/index.tsx index a206d8fa26..407bab644f 100644 --- a/src/components/recovery/RecoveryStatus/index.tsx +++ b/src/components/recovery/RecoveryStatus/index.tsx @@ -6,7 +6,7 @@ import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' import type { RecoveryQueueItem } from '@/services/recovery/recovery-state' export const RecoveryStatus = ({ recovery }: { recovery: RecoveryQueueItem }): ReactElement => { - const { isExecutable, isExpired } = useRecoveryTxState(recovery) + const { isExecutable, isExpired, isPending } = useRecoveryTxState(recovery) return ( <> @@ -17,7 +17,9 @@ export const RecoveryStatus = ({ recovery }: { recovery: RecoveryQueueItem }): R display="inline-flex" alignItems="center" > - {isExecutable ? ( + {isPending ? ( + 'Processing...' + ) : isExecutable ? ( 'Awaiting execution' ) : isExpired ? ( 'Expired' diff --git a/src/hooks/__tests__/useRecoveryTxState.test.tsx b/src/hooks/__tests__/useRecoveryTxState.test.tsx index 3ee6a0ec47..02ed0def21 100644 --- a/src/hooks/__tests__/useRecoveryTxState.test.tsx +++ b/src/hooks/__tests__/useRecoveryTxState.test.tsx @@ -50,10 +50,13 @@ describe('useRecoveryTxState', () => { ), }) - expect(result.current.isExecutable).toBe(false) - expect(result.current.remainingSeconds).toBe(1) - expect(result.current.isExpired).toBe(false) - expect(result.current.isNext).toBe(true) + expect(result.current).toStrictEqual({ + isExecutable: false, + remainingSeconds: 1, + isExpired: false, + isNext: true, + isPending: false, + }) }) it('should return correct values when validFrom is in the future and expiresAt is in the future', () => { @@ -87,10 +90,13 @@ describe('useRecoveryTxState', () => { ), }) - expect(result.current.isExecutable).toBe(false) - expect(result.current.remainingSeconds).toBe(1) - expect(result.current.isExpired).toBe(false) - expect(result.current.isNext).toBe(true) + expect(result.current).toStrictEqual({ + isExecutable: false, + remainingSeconds: 1, + isExpired: false, + isNext: true, + isPending: false, + }) }) it('should return correct values when validFrom is in the past and expiresAt is in the future', () => { @@ -124,10 +130,13 @@ describe('useRecoveryTxState', () => { ), }) - expect(result.current.isExecutable).toBe(true) - expect(result.current.remainingSeconds).toBe(0) - expect(result.current.isExpired).toBe(false) - expect(result.current.isNext).toBe(true) + expect(result.current).toStrictEqual({ + isExecutable: true, + remainingSeconds: 0, + isExpired: false, + isNext: true, + isPending: false, + }) }) it('should return correct values when validFrom is in the past and expiresAt is in the past', () => { @@ -161,10 +170,55 @@ describe('useRecoveryTxState', () => { ), }) - expect(result.current.isExecutable).toBe(false) - expect(result.current.remainingSeconds).toBe(0) - expect(result.current.isExpired).toBe(true) - expect(result.current.isNext).toBe(true) + expect(result.current).toStrictEqual({ + isExecutable: false, + remainingSeconds: 0, + isExpired: true, + isNext: true, + isPending: false, + }) + }) + + it('should return pending if the transaction hash is set as pending', () => { + jest.setSystemTime(0) + + const delayModifierAddress = faker.finance.ethereumAddress() + const nextTxHash = faker.string.hexadecimal() + + const validFrom = BigNumber.from(0) + const expiresAt = BigNumber.from(1) + + const data = [ + { + address: delayModifierAddress, + txNonce: BigNumber.from(0), + queue: [ + { + address: delayModifierAddress, + transactionHash: nextTxHash, + validFrom, + expiresAt, + args: { queueNonce: BigNumber.from(0) }, + }, + ], + }, + ] + + const { result } = renderHook(() => useRecoveryTxState(data[0].queue[0] as any), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(result.current).toStrictEqual({ + isExecutable: true, + remainingSeconds: 0, + isExpired: false, + isNext: true, + isPending: true, + }) }) }) @@ -225,10 +279,13 @@ describe('useRecoveryTxState', () => { ), }) - expect(result.current.isExecutable).toBe(false) - expect(result.current.remainingSeconds).toBe(1) - expect(result.current.isExpired).toBe(false) - expect(result.current.isNext).toBe(false) + expect(result.current).toStrictEqual({ + isExecutable: false, + remainingSeconds: 1, + isExpired: false, + isNext: false, + isPending: false, + }) }) it('should return correct values when validFrom is in the future and expiresAt is in the future', () => { @@ -268,10 +325,13 @@ describe('useRecoveryTxState', () => { ), }) - expect(result.current.isExecutable).toBe(false) - expect(result.current.remainingSeconds).toBe(1) - expect(result.current.isExpired).toBe(false) - expect(result.current.isNext).toBe(false) + expect(result.current).toStrictEqual({ + isExecutable: false, + remainingSeconds: 1, + isExpired: false, + isNext: false, + isPending: false, + }) }) it('should return correct values when validFrom is in the past and expiresAt is in the future', () => { @@ -311,10 +371,13 @@ describe('useRecoveryTxState', () => { ), }) - expect(result.current.isExecutable).toBe(false) - expect(result.current.remainingSeconds).toBe(0) - expect(result.current.isExpired).toBe(false) - expect(result.current.isNext).toBe(false) + expect(result.current).toStrictEqual({ + isExecutable: false, + remainingSeconds: 0, + isExpired: false, + isNext: false, + isPending: false, + }) }) it('should return correct values when validFrom is in the past and expiresAt is in the past', () => { @@ -354,10 +417,13 @@ describe('useRecoveryTxState', () => { ), }) - expect(result.current.isExecutable).toBe(false) - expect(result.current.remainingSeconds).toBe(0) - expect(result.current.isExpired).toBe(true) - expect(result.current.isNext).toBe(false) + expect(result.current).toStrictEqual({ + isExecutable: false, + remainingSeconds: 0, + isExpired: true, + isNext: false, + isPending: false, + }) }) }) }) diff --git a/src/hooks/useRecoveryTxState.ts b/src/hooks/useRecoveryTxState.ts index 1c24df2890..48daeaf4fb 100644 --- a/src/hooks/useRecoveryTxState.ts +++ b/src/hooks/useRecoveryTxState.ts @@ -1,6 +1,8 @@ +import { useContext } from 'react' + import { useClock } from './useClock' import { selectDelayModifierByTxHash } from '@/services/recovery/selectors' -import { useRecovery } from '@/components/recovery/RecoveryContext' +import { RecoveryContext } from '@/components/recovery/RecoveryContext' import { sameAddress } from '@/utils/addresses' import type { RecoveryQueueItem } from '@/services/recovery/recovery-state' @@ -8,9 +10,13 @@ export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args isNext: boolean isExecutable: boolean isExpired: boolean + isPending: boolean remainingSeconds: number } { - const [recovery] = useRecovery() + const { + state: [recovery], + pending, + } = useContext(RecoveryContext) const delayModifier = recovery && selectDelayModifierByTxHash(recovery, transactionHash) // We don't display seconds in the interface, so we can use a 60s interval @@ -24,8 +30,9 @@ export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args const isNext = !delayModifier || (sameAddress(delayModifier.address, address) && args.queueNonce.eq(delayModifier.txNonce)) const isExecutable = isNext && isValid && !isExpired + const isPending = !!pending?.[transactionHash] const remainingSeconds = isValid ? 0 : Math.ceil(remainingMs.div(1_000).toNumber()) - return { isNext, isExecutable, isExpired, remainingSeconds } + return { isNext, isExecutable, isExpired, remainingSeconds, isPending } }