diff --git a/src/hooks/__tests__/useLoadRecovery.test.ts b/src/hooks/__tests__/useLoadRecovery.test.ts new file mode 100644 index 0000000000..e6703996ce --- /dev/null +++ b/src/hooks/__tests__/useLoadRecovery.test.ts @@ -0,0 +1,637 @@ +import { getDelayModifiers } from '@/services/recovery/delay-modifier' +import { faker } from '@faker-js/faker' +import { BigNumber } from 'ethers' +import { JsonRpcProvider } from '@ethersproject/providers' +import type { Delay, TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Delay' + +import useLoadRecovery, { + _getQueuedTransactionsAdded, + _getRecoveryQueueItem, + _getRecoveryState, +} from '../loadables/useLoadRecovery' +import { useHasFeature } from '../useChains' +import useIntervalCounter from '../useIntervalCounter' +import useSafeInfo from '../useSafeInfo' +import { useWeb3ReadOnly } from '../wallets/web3' +import { renderHook, waitFor } from '@testing-library/react' +import { getSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts' + +describe('getQueuedTransactionsAdded', () => { + it('should filter queued transactions with queueNonce >= current txNonce', () => { + const transactionsAdded = [ + { + args: { + queueNonce: BigNumber.from(1), + }, + } as unknown, + { + args: { + queueNonce: BigNumber.from(2), + }, + } as unknown, + { + args: { + queueNonce: BigNumber.from(3), + }, + } as unknown, + ] as Array + + const txNonce = BigNumber.from(2) + + expect(_getQueuedTransactionsAdded(transactionsAdded, txNonce)).toStrictEqual([ + { + args: { + queueNonce: BigNumber.from(2), + }, + } as unknown, + { + args: { + queueNonce: BigNumber.from(3), + }, + }, + ]) + }) +}) + +describe('getRecoveryQueueItem', () => { + it('should return a recovery queue item', async () => { + const transactionAdded = { + getBlock: () => Promise.resolve({ timestamp: 1 }), + } as TransactionAddedEvent + const txCooldown = BigNumber.from(1) + const txExpiration = BigNumber.from(2) + + const item = await _getRecoveryQueueItem(transactionAdded, txCooldown, txExpiration) + + expect(item).toStrictEqual({ + ...transactionAdded, + timestamp: 1, + validFrom: BigNumber.from(2), + expiresAt: BigNumber.from(4), + }) + }) + + it('should return a recovery queue item with expiresAt null if txExpiration is zero', async () => { + const transactionAdded = { + getBlock: () => Promise.resolve({ timestamp: 1 }), + } as TransactionAddedEvent + const txCooldown = BigNumber.from(1) + const txExpiration = BigNumber.from(0) + + const item = await _getRecoveryQueueItem(transactionAdded, txCooldown, txExpiration) + + expect(item).toStrictEqual({ + ...transactionAdded, + timestamp: 1, + validFrom: BigNumber.from(2), + expiresAt: null, + }) + }) +}) + +describe('getRecoveryState', () => { + it('should return the recovery state', async () => { + const modules = [faker.finance.ethereumAddress()] + const txExpiration = BigNumber.from(0) + const txCooldown = BigNumber.from(69420) + const txNonce = BigNumber.from(2) + const queueNonce = BigNumber.from(3) + const transactionsAdded = [ + { + getBlock: () => Promise.resolve({ timestamp: 69 }), + args: { + queueNonce: BigNumber.from(1), + }, + } as unknown, + { + getBlock: () => Promise.resolve({ timestamp: 420 }), + args: { + queueNonce: BigNumber.from(2), + }, + } as unknown, + { + getBlock: () => Promise.resolve({ timestamp: 69420 }), + args: { + queueNonce: BigNumber.from(3), + }, + } as unknown, + ] as Array + + const delayModifier = { + filters: { + TransactionAdded: () => ({}), + }, + address: faker.finance.ethereumAddress(), + getModulesPaginated: () => Promise.resolve([modules]), + txExpiration: () => Promise.resolve(txExpiration), + txCooldown: () => Promise.resolve(txCooldown), + txNonce: () => Promise.resolve(txNonce), + queueNonce: () => Promise.resolve(queueNonce), + queryFilter: () => Promise.resolve(transactionsAdded), + } + + const recoveryState = await _getRecoveryState(delayModifier as unknown as Delay) + + expect(recoveryState).toStrictEqual({ + address: delayModifier.address, + modules, + txExpiration, + txCooldown, + txNonce, + queueNonce, + queue: [ + { + ...transactionsAdded[1], + timestamp: 420, + validFrom: BigNumber.from(420).add(txCooldown), + expiresAt: null, + }, + { + ...transactionsAdded[2], + timestamp: 69420, + validFrom: BigNumber.from(69420).add(txCooldown), + expiresAt: null, + }, + ], + }) + }) +}) + +jest.mock('@/hooks/useSafeInfo') +jest.mock('@/hooks/wallets/web3') +jest.mock('@/hooks/useIntervalCounter') +jest.mock('@/hooks/useChains') +jest.mock('@/services/recovery/delay-modifier') + +const mockUseSafeInfo = useSafeInfo as jest.MockedFunction +const mockUseWeb3ReadOnly = useWeb3ReadOnly as jest.MockedFunction +const mockUseIntervalCounter = useIntervalCounter as jest.MockedFunction +const mockUseHasFeature = useHasFeature as jest.MockedFunction +const mockGetDelayModifiers = getDelayModifiers as jest.MockedFunction + +describe('useLoadRecovery', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return the recovery state', async () => { + // useSafeInfo + mockUseSafeInfo.mockReturnValue({ + safeAddress: faker.finance.ethereumAddress(), + safe: { + chainId: faker.string.numeric(), + modules: [ + { + value: faker.finance.ethereumAddress(), + }, + ], + }, + } as ReturnType) + + // useWeb3ReadOnly + const provider = new JsonRpcProvider() + mockUseWeb3ReadOnly.mockReturnValue(provider) + + // useIntervalCounter + mockUseIntervalCounter.mockReturnValue([0, jest.fn()]) + + // useHasFeature + mockUseHasFeature.mockReturnValue(true) + + // getDelayModifiers + const delayModules = [faker.finance.ethereumAddress()] + const txExpiration = BigNumber.from(0) + const txCooldown = BigNumber.from(69420) + const txNonce = BigNumber.from(2) + const queueNonce = BigNumber.from(3) + const transactionsAdded = [ + { + getBlock: () => Promise.resolve({ timestamp: 69 }), + args: { + queueNonce: BigNumber.from(1), + }, + } as unknown, + { + getBlock: () => Promise.resolve({ timestamp: 420 }), + args: { + queueNonce: BigNumber.from(2), + }, + } as unknown, + { + getBlock: () => Promise.resolve({ timestamp: 69420 }), + args: { + queueNonce: BigNumber.from(3), + }, + } as unknown, + ] as Array + const delayModifier = { + filters: { + TransactionAdded: () => ({}), + }, + address: faker.finance.ethereumAddress(), + getModulesPaginated: () => Promise.resolve([delayModules]), + txExpiration: () => Promise.resolve(txExpiration), + txCooldown: () => Promise.resolve(txCooldown), + txNonce: () => Promise.resolve(txNonce), + queueNonce: () => Promise.resolve(queueNonce), + queryFilter: () => Promise.resolve(transactionsAdded), + } as unknown as Delay + mockGetDelayModifiers.mockResolvedValue([delayModifier]) + + const { result } = renderHook(() => useLoadRecovery()) + + // Loading + expect(result.current).toStrictEqual([undefined, undefined, true]) + + // Loaded + await waitFor(() => { + expect(result.current).toStrictEqual([ + [ + { + address: delayModifier.address, + modules: delayModules, + txExpiration, + txCooldown, + txNonce, + queueNonce, + queue: [ + { + ...transactionsAdded[1], + timestamp: 420, + validFrom: BigNumber.from(420).add(txCooldown), + expiresAt: null, + }, + { + ...transactionsAdded[2], + timestamp: 69420, + validFrom: BigNumber.from(69420).add(txCooldown), + expiresAt: null, + }, + ], + }, + ], + undefined, + false, + ]) + }) + }) + + it('should fetch the recovery state again if the Safe address changes', async () => { + // useSafeInfo + const safeAddress1 = faker.finance.ethereumAddress() + const chainId = faker.string.numeric() + const modules = [ + { + value: faker.finance.ethereumAddress(), + }, + ] + mockUseSafeInfo.mockReturnValue({ + safeAddress: safeAddress1, + safe: { + chainId, + modules, + }, + } as ReturnType) + + // useWeb3ReadOnly + const provider = new JsonRpcProvider() + mockUseWeb3ReadOnly.mockReturnValue(provider) + + // useIntervalCounter + mockUseIntervalCounter.mockReturnValue([0, jest.fn()]) + + // useHasFeature + mockUseHasFeature.mockReturnValue(true) + + // getDelayModifiers + mockGetDelayModifiers.mockImplementation(jest.fn()) + + const { rerender } = renderHook(() => useLoadRecovery()) + + expect(mockGetDelayModifiers).toHaveBeenCalledTimes(1) + + // Safe address changes + const safeAddress2 = faker.finance.ethereumAddress() + mockUseSafeInfo.mockReturnValue({ + safeAddress: safeAddress2, + safe: { + chainId, + modules, + }, + } as ReturnType) + + rerender() + + await waitFor(() => { + expect(mockGetDelayModifiers).toHaveBeenCalledTimes(2) + }) + }) + + it('should fetch the recovery state again if the chain changes', async () => { + // useSafeInfo + const safeAddress = faker.finance.ethereumAddress() + const chainId1 = faker.string.numeric() + const modules = [ + { + value: faker.finance.ethereumAddress(), + }, + ] + mockUseSafeInfo.mockReturnValue({ + safeAddress, + safe: { + chainId: chainId1, + modules, + }, + } as ReturnType) + + // useWeb3ReadOnly + const provider = new JsonRpcProvider() + mockUseWeb3ReadOnly.mockReturnValue(provider) + + // useIntervalCounter + mockUseIntervalCounter.mockReturnValue([0, jest.fn()]) + + // useHasFeature + mockUseHasFeature.mockReturnValue(true) + + // getDelayModifiers + mockGetDelayModifiers.mockImplementation(jest.fn()) + + const { rerender } = renderHook(() => useLoadRecovery()) + + expect(mockGetDelayModifiers).toHaveBeenCalledTimes(1) + + // Chain changes + const chainId2 = faker.string.numeric({ exclude: chainId1 }) + mockUseSafeInfo.mockReturnValue({ + safeAddress, + safe: { + chainId: chainId2, + modules, + }, + } as ReturnType) + + rerender() + + await waitFor(() => { + expect(mockGetDelayModifiers).toHaveBeenCalledTimes(2) + }) + }) + + it('should fetch the recovery state again if the enabled modules change', async () => { + // useSafeInfo + const safeAddress = faker.finance.ethereumAddress() + const chainId = faker.string.numeric() + const modules1 = [ + { + value: faker.finance.ethereumAddress(), + }, + ] + mockUseSafeInfo.mockReturnValue({ + safeAddress, + safe: { + chainId, + modules: modules1, + }, + } as ReturnType) + + // useWeb3ReadOnly + const provider = new JsonRpcProvider() + mockUseWeb3ReadOnly.mockReturnValue(provider) + + // useIntervalCounter + mockUseIntervalCounter.mockReturnValue([0, jest.fn()]) + + // useHasFeature + mockUseHasFeature.mockReturnValue(true) + + // getDelayModifiers + mockGetDelayModifiers.mockImplementation(jest.fn()) + + const { rerender } = renderHook(() => useLoadRecovery()) + + expect(mockGetDelayModifiers).toHaveBeenCalledTimes(1) + + // Modules changes (module is added) + const modules2 = [ + ...modules1, + { + value: faker.finance.ethereumAddress(), + }, + ] + mockUseSafeInfo.mockReturnValue({ + safeAddress, + safe: { + chainId, + modules: modules2, + }, + } as ReturnType) + + rerender() + + await waitFor(() => { + expect(mockGetDelayModifiers).toHaveBeenCalledTimes(2) + }) + }) + + it.skip('should poll the recovery state every 5 minutes', async () => { + jest.useFakeTimers() + + // useSafeInfo + mockUseSafeInfo.mockReturnValue({ + safeAddress: faker.finance.ethereumAddress(), + safe: { + chainId: faker.string.numeric(), + modules: [ + { + value: faker.finance.ethereumAddress(), + }, + ], + }, + } as ReturnType) + + // useWeb3ReadOnly + const provider = new JsonRpcProvider() + mockUseWeb3ReadOnly.mockReturnValue(provider) + + // useIntervalCounter + mockUseIntervalCounter.mockImplementation(useIntervalCounter) // TODO: Fix this + + // useHasFeature + mockUseHasFeature.mockReturnValue(true) + + // getDelayModifiers + mockGetDelayModifiers.mockImplementation(jest.fn()) + + renderHook(() => useLoadRecovery()) + + expect(mockGetDelayModifiers).toHaveBeenCalledTimes(1) + + jest.advanceTimersByTime(5 * 60 * 1_000) + + await waitFor(() => { + expect(mockGetDelayModifiers).toHaveBeenCalledTimes(2) + }) + + jest.useRealTimers() + }) + + it('should not return the recovery state if the chain does not support recovery', async () => { + // useSafeInfo + mockUseSafeInfo.mockReturnValue({ + safeAddress: faker.finance.ethereumAddress(), + safe: { + chainId: faker.string.numeric(), + modules: [ + { + value: faker.finance.ethereumAddress(), + }, + ], + }, + } as ReturnType) + + // useWeb3ReadOnly + const provider = new JsonRpcProvider() + mockUseWeb3ReadOnly.mockReturnValue(provider) + + // useIntervalCounter + mockUseIntervalCounter.mockReturnValue([0, jest.fn()]) + + // useHasFeature + mockUseHasFeature.mockReturnValue(false) // Does not support recovery + + renderHook(() => useLoadRecovery()) + + await waitFor(() => { + expect(mockGetDelayModifiers).not.toHaveBeenCalled() + }) + }) + + it('should not return the recovery state if there is no provider', async () => { + // useSafeInfo + mockUseSafeInfo.mockReturnValue({ + safeAddress: faker.finance.ethereumAddress(), + safe: { + chainId: faker.string.numeric(), + modules: [ + { + value: faker.finance.ethereumAddress(), + }, + ], + }, + } as ReturnType) + + // useWeb3ReadOnly + mockUseWeb3ReadOnly.mockReturnValue(undefined) // No provider + + // useIntervalCounter + mockUseIntervalCounter.mockReturnValue([0, jest.fn()]) + + // useHasFeature + mockUseHasFeature.mockReturnValue(true) + + renderHook(() => useLoadRecovery()) + + await waitFor(() => { + expect(mockGetDelayModifiers).not.toHaveBeenCalled() + }) + }) + + it('should not return the recovery state if the Safe has no modules', async () => { + // useSafeInfo + mockUseSafeInfo.mockReturnValue({ + safeAddress: faker.finance.ethereumAddress(), + safe: { + chainId: faker.string.numeric(), + modules: [], // No modules enabled + }, + } as unknown as ReturnType) + + // useWeb3ReadOnly + const provider = new JsonRpcProvider() + mockUseWeb3ReadOnly.mockReturnValue(provider) + + // useIntervalCounter + mockUseIntervalCounter.mockReturnValue([0, jest.fn()]) + + // useHasFeature + mockUseHasFeature.mockReturnValue(true) + + renderHook(() => useLoadRecovery()) + + await waitFor(() => { + expect(mockGetDelayModifiers).not.toHaveBeenCalled() + }) + }) + + it('should not check for delay modifiers if only the spending limit module is enabled', async () => { + const chainId = faker.string.numeric() + + // useSafeInfo + mockUseSafeInfo.mockReturnValue({ + safeAddress: faker.finance.ethereumAddress(), + safe: { + chainId, + modules: [ + { + value: getSpendingLimitModuleAddress(chainId), + }, + ], // Only spending limit module enabled + }, + } as ReturnType) + + // useWeb3ReadOnly + const provider = new JsonRpcProvider() + mockUseWeb3ReadOnly.mockReturnValue(provider) + + // useIntervalCounter + mockUseIntervalCounter.mockReturnValue([0, jest.fn()]) + + // useHasFeature + mockUseHasFeature.mockReturnValue(true) + + renderHook(() => useLoadRecovery()) + + await waitFor(() => { + expect(mockGetDelayModifiers).not.toHaveBeenCalled() + }) + }) + + it('should not return the recovery state if no delay modifier is enabled', async () => { + // useSafeInfo + mockUseSafeInfo.mockReturnValue({ + safeAddress: faker.finance.ethereumAddress(), + safe: { + chainId: faker.string.numeric(), + modules: [ + { + value: faker.finance.ethereumAddress(), + }, + ], + }, + } as ReturnType) + + // useWeb3ReadOnly + const provider = new JsonRpcProvider() + mockUseWeb3ReadOnly.mockReturnValue(provider) + + // useIntervalCounter + mockUseIntervalCounter.mockReturnValue([0, jest.fn()]) + + // useHasFeature + mockUseHasFeature.mockReturnValue(true) + + // getDelayModifiers + mockGetDelayModifiers.mockResolvedValue([]) // No Delay Modifiers + + const { result } = renderHook(() => useLoadRecovery()) + + // Loading + expect(result.current).toStrictEqual([undefined, undefined, true]) + + // Loaded + await waitFor(() => { + expect(result.current).toStrictEqual([undefined, undefined, false]) + }) + }) +}) diff --git a/src/hooks/loadables/useLoadRecovery.ts b/src/hooks/loadables/useLoadRecovery.ts index 21caab93d4..5c49fd32d8 100644 --- a/src/hooks/loadables/useLoadRecovery.ts +++ b/src/hooks/loadables/useLoadRecovery.ts @@ -1,6 +1,7 @@ import { SENTINEL_ADDRESS } from '@safe-global/safe-core-sdk/dist/src/utils/constants' import { BigNumber } from 'ethers' import type { Delay } from '@gnosis.pm/zodiac' +import type { TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Delay' import { getDelayModifiers } from '@/services/recovery/delay-modifier' import useAsync from '../useAsync' @@ -11,12 +12,40 @@ import useIntervalCounter from '../useIntervalCounter' import { useHasFeature } from '../useChains' import { FEATURES } from '@/utils/chains' import type { AsyncResult } from '../useAsync' -import type { RecoveryState } from '@/store/recoverySlice' +import type { RecoveryQueueItem, RecoveryState } from '@/store/recoverySlice' const MAX_PAGE_SIZE = 100 const REFRESH_DELAY = 5 * 60 * 1_000 // 5 minutes -const getRecoveryState = async (delayModifier: Delay): Promise => { +export const _getQueuedTransactionsAdded = ( + transactionsAdded: Array, + txNonce: BigNumber, +): Array => { + // Only queued transactions with queueNonce >= current txNonce + return transactionsAdded.filter(({ args }) => args.queueNonce.gte(txNonce)) +} + +export const _getRecoveryQueueItem = async ( + transactionAdded: TransactionAddedEvent, + txCooldown: BigNumber, + txExpiration: BigNumber, +): Promise => { + const txBlock = await transactionAdded.getBlock() + + const validFrom = BigNumber.from(txBlock.timestamp).add(txCooldown) + const expiresAt = txExpiration.isZero() + ? null // Never expires + : validFrom.add(txExpiration) + + return { + ...transactionAdded, + timestamp: txBlock.timestamp, + validFrom, + expiresAt, + } +} + +export const _getRecoveryState = async (delayModifier: Delay): Promise => { const transactionAddedFilter = delayModifier.filters.TransactionAdded() const [[modules], txExpiration, txCooldown, txNonce, queueNonce, transactionsAdded] = await Promise.all([ @@ -28,25 +57,12 @@ const getRecoveryState = async (delayModifier: Delay): Promise= current txNonce - .filter(({ args }) => args.queueNonce.gte(txNonce)) - .map(async (event) => { - const txBlock = await event.getBlock() - - const validFrom = BigNumber.from(txBlock.timestamp).add(txCooldown) - const expiresAt = txExpiration.isZero() - ? null // Never expires - : validFrom.add(txExpiration) - - return { - ...event, - timestamp: txBlock.timestamp, - validFrom, - expiresAt, - } - }), + queuedTransactionsAdded.map((transactionAdded) => + _getRecoveryQueueItem(transactionAdded, txCooldown, txExpiration), + ), ) return { @@ -61,7 +77,7 @@ const getRecoveryState = async (delayModifier: Delay): Promise => { - const { safe } = useSafeInfo() + const { safe, safeAddress } = useSafeInfo() const web3ReadOnly = useWeb3ReadOnly() const [counter] = useIntervalCounter(REFRESH_DELAY) const supportsRecovery = useHasFeature(FEATURES.RECOVERY) @@ -81,14 +97,14 @@ const useLoadRecovery = (): AsyncResult => { return getDelayModifiers(safe.chainId, safe.modules, web3ReadOnly) // Need to check length of modules array to prevent new request every time Safe info polls // eslint-disable-next-line react-hooks/exhaustive-deps - }, [safe.chainId, safe.modules?.length, web3ReadOnly, supportsRecovery]) + }, [safeAddress, safe.chainId, safe.modules?.length, web3ReadOnly, supportsRecovery]) const [recoveryState, recoveryStateError, recoveryStateLoading] = useAsync(() => { if (!delayModifiers || delayModifiers.length === 0) { return } - return Promise.all(delayModifiers.map(getRecoveryState)) + return Promise.all(delayModifiers.map(_getRecoveryState)) // eslint-disable-next-line react-hooks/exhaustive-deps }, [delayModifiers, counter]) diff --git a/src/services/recovery/__tests__/delay-modifier.test.ts b/src/services/recovery/__tests__/delay-modifier.test.ts new file mode 100644 index 0000000000..b6f2785b1a --- /dev/null +++ b/src/services/recovery/__tests__/delay-modifier.test.ts @@ -0,0 +1,112 @@ +import { faker } from '@faker-js/faker' +import type { JsonRpcProvider } from '@ethersproject/providers' + +import { isOfficialDelayModifier } from '../delay-modifier' +import * as proxies from '../proxies' + +const DELAY_MODULE = { + '1.0.0': '0xD62129BF40CD1694b3d9D9847367783a1A4d5cB4', + '1.0.1': '0xd54895B1121A2eE3f37b502F507631FA1331BED6', +} +const DELAY_MODULE_ADDRESSES = Object.values(DELAY_MODULE) + +describe('delay-modifier', () => { + describe('isOfficialDelayModifier', () => { + DELAY_MODULE_ADDRESSES.forEach((moduleAddress) => { + it('should return true for an official Delay Modifier', async () => { + const chainId = '5' + const bytecode = '0x' + faker.string.hexadecimal() + const provider = { + getCode: () => Promise.resolve(bytecode), + } as unknown as JsonRpcProvider + + const isOfficial = await isOfficialDelayModifier(chainId, moduleAddress, provider) + expect(isOfficial).toBe(true) + }) + }) + + it('should otherwise return false', async () => { + const chainId = '5' + const moduleAddress = faker.finance.ethereumAddress() + const bytecode = '0x' + faker.string.hexadecimal() + const provider = { + getCode: () => Promise.resolve(bytecode), + } as unknown as JsonRpcProvider + + const isOfficial = await isOfficialDelayModifier(chainId, moduleAddress, provider) + expect(isOfficial).toBe(false) + }) + + describe('generic proxies', () => { + const genericProxyBytecode = + '0x363d3d373d3d3d363d73' + faker.string.hexadecimal({ length: 38 }) + '5af43d82803e903d91602b57fd5bf3' + + DELAY_MODULE_ADDRESSES.forEach((moduleAddress) => { + it('should return true for an official Delay Modifier', async () => { + const chainId = '5' + const proxyAddress = faker.finance.ethereumAddress() + const bytecode = faker.string.hexadecimal() + const provider = { + getCode: jest.fn().mockResolvedValueOnce(genericProxyBytecode).mockResolvedValue(bytecode), + } as unknown as JsonRpcProvider + + jest.spyOn(proxies, 'getGenericProxyMasterCopy').mockReturnValue(moduleAddress) + + const isOfficial = await isOfficialDelayModifier(chainId, proxyAddress, provider) + expect(isOfficial).toBe(true) + }) + }) + + it('should otherwise return false', async () => { + const chainId = '5' + const proxyAddress = faker.finance.ethereumAddress() + const moduleAddress = faker.finance.ethereumAddress() + const bytecode = faker.string.hexadecimal() + const provider = { + getCode: jest.fn().mockResolvedValueOnce(genericProxyBytecode).mockResolvedValue(bytecode), + } as unknown as JsonRpcProvider + + jest.spyOn(proxies, 'getGenericProxyMasterCopy').mockReturnValue(moduleAddress) + + const isOfficial = await isOfficialDelayModifier(chainId, proxyAddress, provider) + expect(isOfficial).toBe(false) + }) + }) + + describe('Gnosis generic proxies', () => { + const gnosisGenericProxyBytecode = + '0x608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea265627a7a72315820d8a00dc4fe6bf675a9d7416fc2d00bb3433362aa8186b750f76c4027269667ff64736f6c634300050e0032' + + DELAY_MODULE_ADDRESSES.forEach((moduleAddress) => { + it('should return true for an official Delay Modifier', async () => { + const chainId = '5' + const proxyAddress = faker.finance.ethereumAddress() + const bytecode = faker.string.hexadecimal() + const provider = { + getCode: jest.fn().mockResolvedValueOnce(gnosisGenericProxyBytecode).mockResolvedValue(bytecode), + } as unknown as JsonRpcProvider + + jest.spyOn(proxies, 'getGnosisGenericProxyMasterCopy').mockResolvedValue(moduleAddress) + + const isOfficial = await isOfficialDelayModifier(chainId, proxyAddress, provider) + expect(isOfficial).toBe(true) + }) + }) + + it('should otherwise return false', async () => { + const chainId = '5' + const proxyAddress = faker.finance.ethereumAddress() + const moduleAddress = faker.finance.ethereumAddress() + const bytecode = faker.string.hexadecimal() + const provider = { + getCode: jest.fn().mockResolvedValueOnce(gnosisGenericProxyBytecode).mockResolvedValue(bytecode), + } as unknown as JsonRpcProvider + + jest.spyOn(proxies, 'getGnosisGenericProxyMasterCopy').mockResolvedValue(moduleAddress) + + const isOfficial = await isOfficialDelayModifier(chainId, proxyAddress, provider) + expect(isOfficial).toBe(false) + }) + }) + }) +}) diff --git a/src/services/recovery/__tests__/proxies.test.ts b/src/services/recovery/__tests__/proxies.test.ts new file mode 100644 index 0000000000..6ddd68fe7a --- /dev/null +++ b/src/services/recovery/__tests__/proxies.test.ts @@ -0,0 +1,39 @@ +import { faker } from '@faker-js/faker' + +import { getGenericProxyMasterCopy, isGenericProxy, isGnosisProxy } from '../proxies' + +describe('proxies', () => { + describe('isGenericProxy', () => { + it('should return true for a generic proxy', () => { + const genericProxyBytecode = + '0x363d3d373d3d3d363d73' + faker.string.hexadecimal({ length: 38 }) + '5af43d82803e903d91602b57fd5bf3' + expect(isGenericProxy(genericProxyBytecode)).toBe(true) + }) + + it('should return false for a non-generic proxy', () => { + const bytecode = '0x' + faker.string.hexadecimal() + expect(isGenericProxy(bytecode)).toBe(false) + }) + }) + + describe('isGnosisGenericProxy', () => { + it('should return true for a Gnosis generic proxy', () => { + const gnosisGenericProxyBytecode = + '0x608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea265627a7a72315820d8a00dc4fe6bf675a9d7416fc2d00bb3433362aa8186b750f76c4027269667ff64736f6c634300050e0032' + expect(isGnosisProxy(gnosisGenericProxyBytecode)).toBe(true) + }) + + it('should return false for a non-Gnosis generic proxy', () => { + const bytecode = '0x' + faker.string.hexadecimal() + expect(isGnosisProxy(bytecode)).toBe(false) + }) + }) + + describe('getGenericProxyMasterCopy', () => { + it('should return the master copy address', () => { + const genericProxyBytecode = + '0x363d3d373d3d3d363d73' + faker.string.hexadecimal({ length: 38 }) + '5af43d82803e903d91602b57fd5bf3' + expect(getGenericProxyMasterCopy(genericProxyBytecode)).toBe('0x' + genericProxyBytecode.slice(22, 62)) + }) + }) +}) diff --git a/src/services/recovery/proxies.ts b/src/services/recovery/proxies.ts index c6fd05d68a..a09d3a6dce 100644 --- a/src/services/recovery/proxies.ts +++ b/src/services/recovery/proxies.ts @@ -1,6 +1,9 @@ import { Contract } from 'ethers' import type { JsonRpcProvider } from '@ethersproject/providers' +// zodiac-safe-app used as reference for proxy detection +// @see https://github.com/gnosis/zodiac-safe-app/blob/e5d6d3d251d128245104ddc638e26d290689bb14/packages/app/src/utils/modulesValidation.ts + export function isGenericProxy(bytecode: string): boolean { if (bytecode.length !== 92) { return false diff --git a/src/store/recoverySlice.ts b/src/store/recoverySlice.ts index a570e88325..1755799cf3 100644 --- a/src/store/recoverySlice.ts +++ b/src/store/recoverySlice.ts @@ -4,7 +4,7 @@ import type { BigNumber } from 'ethers' import { makeLoadableSlice } from './common' -type QueuedTransactionAdded = TransactionAddedEvent & { +export type RecoveryQueueItem = TransactionAddedEvent & { timestamp: number validFrom: BigNumber expiresAt: BigNumber | null @@ -17,7 +17,7 @@ export type RecoveryState = Array<{ txCooldown: BigNumber txNonce: BigNumber queueNonce: BigNumber - queue: Array + queue: Array }> const initialState: RecoveryState = []