Skip to content

Commit

Permalink
feat: recovery transaction processing state
Browse files Browse the repository at this point in the history
  • Loading branch information
iamacook committed Nov 28, 2023
1 parent 219ff05 commit ba80fda
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 44 deletions.
4 changes: 2 additions & 2 deletions src/components/recovery/CancelRecoveryButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -61,7 +61,7 @@ export function CancelRecoveryButton({
return (
<CheckWallet allowNonOwner>
{(isOk) => {
const isDisabled = !isOk || (isGuardian && !isExpired)
const isDisabled = !isOk || isPending || (isGuardian && !isExpired)

return compact ? (
<IconButton onClick={onClick} color="error" size="small" disabled={isDisabled}>
Expand Down
12 changes: 10 additions & 2 deletions src/components/recovery/ExecuteRecoveryButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -36,6 +37,8 @@ export function ExecuteRecoveryButton({
return
}

setPending((prev) => ({ ...prev, [recovery.transactionHash]: true }))

try {
await dispatchRecoveryExecution({
onboard,
Expand All @@ -49,13 +52,18 @@ export function ExecuteRecoveryButton({

trackError(Errors._812, e)
setSubmitError(err)
} finally {
setPending((prev) => {
const { [recovery.transactionHash]: _, ...rest } = prev
return rest
})
}
}

return (
<CheckWallet allowNonOwner>
{(isOk) => {
const isDisabled = !isOk || !isExecutable
const isDisabled = !isOk || !isExecutable || isPending

return (
<Tooltip title={isDisabled ? 'Previous recovery attempts must be executed or cancelled first' : null}>
Expand Down
15 changes: 12 additions & 3 deletions src/components/recovery/RecoveryContext/index.tsx
Original file line number Diff line number Diff line change
@@ -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<RecoveryState>
refetch: () => void
pending: PendingRecoveryTransactions
setPending: Dispatch<SetStateAction<PendingRecoveryTransactions>>
}>({
state: [undefined, undefined, false],
refetch: () => {},
pending: {},
setPending: () => {},
})

export function RecoveryProvider({ children }: { children: ReactNode }): ReactElement {
Expand All @@ -21,13 +27,16 @@ export function RecoveryProvider({ children }: { children: ReactNode }): ReactEl
data: [recoveryState, recoveryStateError, recoveryStateLoading],
refetch,
} = useRecoveryState(delayModifiers)
const [pending, setPending] = useState<PendingRecoveryTransactions>({})

const data = recoveryState
const error = delayModifiersError || recoveryStateError
const loading = delayModifiersLoading || recoveryStateLoading

return (
<RecoveryContext.Provider value={{ state: [data, error, loading], refetch }}>{children}</RecoveryContext.Provider>
<RecoveryContext.Provider value={{ state: [data, error, loading], refetch, pending, setPending }}>
{children}
</RecoveryContext.Provider>
)
}

Expand Down
6 changes: 4 additions & 2 deletions src/components/recovery/RecoveryStatus/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
Expand All @@ -17,7 +17,9 @@ export const RecoveryStatus = ({ recovery }: { recovery: RecoveryQueueItem }): R
display="inline-flex"
alignItems="center"
>
{isExecutable ? (
{isPending ? (
'Processing...'
) : isExecutable ? (
'Awaiting execution'
) : isExpired ? (
'Expired'
Expand Down
130 changes: 98 additions & 32 deletions src/hooks/__tests__/useRecoveryTxState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 }) => (
<RecoveryContext.Provider value={{ state: [data], pending: { [nextTxHash]: true } } as any}>
{children}
</RecoveryContext.Provider>
),
})

expect(result.current).toStrictEqual({
isExecutable: true,
remainingSeconds: 0,
isExpired: false,
isNext: true,
isPending: true,
})
})
})

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
})
})
})
})
13 changes: 10 additions & 3 deletions src/hooks/useRecoveryTxState.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
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'

export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args, address }: RecoveryQueueItem): {
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
Expand All @@ -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 }
}

0 comments on commit ba80fda

Please sign in to comment.