From 2cecd77dee3e73db025b05d671aaa8c823e15321 Mon Sep 17 00:00:00 2001 From: iamacook Date: Tue, 7 Nov 2023 17:58:15 +0100 Subject: [PATCH] feat: poll recovery state of Safe --- package.json | 1 + src/hooks/loadables/useLoadRecovery.ts | 71 +++++++++++++++++++++++++ src/hooks/useLoadableStores.ts | 3 ++ src/services/recovery/delay-modifier.ts | 58 ++++++++++++++++++++ src/services/recovery/proxies.ts | 30 +++++++++++ src/store/index.ts | 2 + src/store/recoverySlice.ts | 21 ++++++++ src/tests/builders/address-ex.ts | 13 +++++ src/tests/builders/safe.ts | 44 +++++++++++++++ yarn.lock | 33 +++++++++++- 10 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 src/hooks/loadables/useLoadRecovery.ts create mode 100644 src/services/recovery/delay-modifier.ts create mode 100644 src/services/recovery/proxies.ts create mode 100644 src/store/recoverySlice.ts create mode 100644 src/tests/builders/address-ex.ts create mode 100644 src/tests/builders/safe.ts diff --git a/package.json b/package.json index 9882a430e1..d58949d6b9 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@emotion/react": "^11.10.0", "@emotion/server": "^11.10.0", "@emotion/styled": "^11.10.0", + "@gnosis.pm/zodiac": "^3.4.2", "@mui/icons-material": "^5.14.3", "@mui/material": "^5.14.3", "@mui/x-date-pickers": "^5.0.12", diff --git a/src/hooks/loadables/useLoadRecovery.ts b/src/hooks/loadables/useLoadRecovery.ts new file mode 100644 index 0000000000..17ebfd953d --- /dev/null +++ b/src/hooks/loadables/useLoadRecovery.ts @@ -0,0 +1,71 @@ +import { SENTINEL_ADDRESS } from '@safe-global/safe-core-sdk/dist/src/utils/constants' +import type { Delay } from '@gnosis.pm/zodiac' + +import { getDelayModifiers } from '@/services/recovery/delay-modifier' +import useAsync from '../useAsync' +import useSafeInfo from '../useSafeInfo' +import { useWeb3ReadOnly } from '../wallets/web3' +import { getSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts' +import useIntervalCounter from '../useIntervalCounter' +import type { AsyncResult } from '../useAsync' +import type { RecoveryState } from '@/store/recoverySlice' + +const MAX_PAGE_SIZE = 100 +const REFRESH_DELAY = 5 * 60 * 1_000 // 5 minutes + +const getRecoveryState = async (delayModifier: Delay): Promise => { + const transactionAddedFilter = delayModifier.filters.TransactionAdded() + + const [[modules], txCooldown, txNonce, queueNonce, transactionsAdded] = await Promise.all([ + delayModifier.getModulesPaginated(SENTINEL_ADDRESS, MAX_PAGE_SIZE), + delayModifier.txCooldown(), + delayModifier.txNonce(), + delayModifier.queueNonce(), + delayModifier.queryFilter(transactionAddedFilter), + ]) + + return { + address: delayModifier.address, + modules, + txCooldown: txCooldown.toString(), + txNonce: txNonce.toString(), + queueNonce: queueNonce.toString(), + transactionsAdded: transactionsAdded.filter(({ args }) => args.queueNonce.gte(txNonce)), + } +} + +const useLoadRecovery = (): AsyncResult => { + const { safe } = useSafeInfo() + const web3ReadOnly = useWeb3ReadOnly() + const [counter] = useIntervalCounter(REFRESH_DELAY) + + const [delayModifiers, delayModifiersError, delayModifiersLoading] = useAsync>(() => { + if (!web3ReadOnly || !safe.modules || safe.modules.length === 0) { + return + } + + const isOnlySpendingLimit = + safe.modules.length === 1 && safe.modules[0].value === getSpendingLimitModuleAddress(safe.chainId) + + if (isOnlySpendingLimit) { + return + } + + 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]) + + const [recoveryState, recoveryStateError, recoveryStateLoading] = useAsync(() => { + if (!delayModifiers || delayModifiers.length === 0) { + return + } + + return Promise.all(delayModifiers.map(getRecoveryState)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [delayModifiers, counter]) + + return [recoveryState, delayModifiersError || recoveryStateError, delayModifiersLoading || recoveryStateLoading] +} + +export default useLoadRecovery diff --git a/src/hooks/useLoadableStores.ts b/src/hooks/useLoadableStores.ts index 0a0ed24fda..b49a02de03 100644 --- a/src/hooks/useLoadableStores.ts +++ b/src/hooks/useLoadableStores.ts @@ -10,6 +10,7 @@ import useLoadBalances from './loadables/useLoadBalances' import useLoadTxHistory from './loadables/useLoadTxHistory' import useLoadTxQueue from './loadables/useLoadTxQueue' import useLoadMessages from './loadables/useLoadSafeMessages' +import useLoadRecovery from './loadables/useLoadRecovery' // Import all the loadable slices import { chainsSlice } from '@/store/chainsSlice' @@ -20,6 +21,7 @@ import { txQueueSlice } from '@/store/txQueueSlice' import { spendingLimitSlice } from '@/store/spendingLimitsSlice' import useLoadSpendingLimits from '@/hooks/loadables/useLoadSpendingLimits' import { safeMessagesSlice } from '@/store/safeMessagesSlice' +import { recoverySlice } from '@/store/recoverySlice' // Dispatch into the corresponding store when the loadable is loaded const useUpdateStore = (slice: Slice, useLoadHook: () => AsyncResult): void => { @@ -46,6 +48,7 @@ const useLoadableStores = () => { useUpdateStore(txQueueSlice, useLoadTxQueue) useUpdateStore(safeMessagesSlice, useLoadMessages) useUpdateStore(spendingLimitSlice, useLoadSpendingLimits) + useUpdateStore(recoverySlice, useLoadRecovery) } export default useLoadableStores diff --git a/src/services/recovery/delay-modifier.ts b/src/services/recovery/delay-modifier.ts new file mode 100644 index 0000000000..d945b9f825 --- /dev/null +++ b/src/services/recovery/delay-modifier.ts @@ -0,0 +1,58 @@ +import { ContractVersions, getModuleInstance, KnownContracts } from '@gnosis.pm/zodiac' +import type { Delay, SupportedNetworks } from '@gnosis.pm/zodiac' +import type { JsonRpcProvider } from '@ethersproject/providers' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import { sameAddress } from '@/utils/addresses' +import { getGenericProxyMasterCopy, getGnosisProxyMasterCopy, isGenericProxy, isGnosisProxy } from './proxies' + +export async function isOfficialDelayModifier( + chainId: string, + moduleAddress: string, + provider: JsonRpcProvider, +): Promise { + const bytecode = await provider.getCode(moduleAddress) + + if (isGenericProxy(bytecode)) { + const masterCopy = getGenericProxyMasterCopy(bytecode) + return await isOfficialDelayModifier(chainId, masterCopy, provider) + } + + if (isGnosisProxy(bytecode)) { + const masterCopy = await getGnosisProxyMasterCopy(moduleAddress, provider) + return await isOfficialDelayModifier(chainId, masterCopy, provider) + } + + const zodiacChainContracts = ContractVersions[Number(chainId) as SupportedNetworks] + const zodiacContract = Object.entries(zodiacChainContracts).find(([, addresses]) => { + return Object.values(addresses).some((address) => { + return sameAddress(address, moduleAddress) + }) + }) + + return zodiacContract?.[0] === KnownContracts.DELAY +} + +export async function getDelayModifiers( + chainId: string, + modules: SafeInfo['modules'], + provider: JsonRpcProvider, +): Promise> { + if (!modules) { + return [] + } + + const instances = await Promise.all( + modules.map(async ({ value }) => { + const isDelayModifier = await isOfficialDelayModifier(chainId, value, provider) + + if (!isDelayModifier) { + return null + } + + return getModuleInstance(KnownContracts.DELAY, value, provider) + }), + ) + + return instances.filter(Boolean) as Array +} diff --git a/src/services/recovery/proxies.ts b/src/services/recovery/proxies.ts new file mode 100644 index 0000000000..c6fd05d68a --- /dev/null +++ b/src/services/recovery/proxies.ts @@ -0,0 +1,30 @@ +import { Contract } from 'ethers' +import type { JsonRpcProvider } from '@ethersproject/providers' + +export function isGenericProxy(bytecode: string): boolean { + if (bytecode.length !== 92) { + return false + } + + return bytecode.startsWith('0x363d3d373d3d3d363d73') && bytecode.endsWith('5af43d82803e903d91602b57fd5bf3') +} + +export function getGenericProxyMasterCopy(bytecode: string): string { + return '0x' + bytecode.slice(22, 62) +} + +export function isGnosisProxy(bytecode: string): boolean { + return ( + bytecode.toLowerCase() === + '0x608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea265627a7a72315820d8a00dc4fe6bf675a9d7416fc2d00bb3433362aa8186b750f76c4027269667ff64736f6c634300050e0032' + ) +} + +export async function getGnosisProxyMasterCopy(address: string, provider: JsonRpcProvider): Promise { + const gnosisProxyAbi = ['function masterCopy() external view returns (address)'] + const gnosisProxyContract = new Contract(address, gnosisProxyAbi, provider) + + const [masterCopy] = await gnosisProxyContract.masterCopy() + + return masterCopy +} diff --git a/src/store/index.ts b/src/store/index.ts index 5053c84f8a..126f5adce5 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -29,6 +29,7 @@ import { safeAppsSlice } from './safeAppsSlice' import { safeMessagesListener, safeMessagesSlice } from './safeMessagesSlice' import { pendingSafeMessagesSlice } from './pendingSafeMessagesSlice' import { batchSlice } from './batchSlice' +import { recoverySlice } from './recoverySlice' const rootReducer = combineReducers({ [chainsSlice.name]: chainsSlice.reducer, @@ -49,6 +50,7 @@ const rootReducer = combineReducers({ [safeMessagesSlice.name]: safeMessagesSlice.reducer, [pendingSafeMessagesSlice.name]: pendingSafeMessagesSlice.reducer, [batchSlice.name]: batchSlice.reducer, + [recoverySlice.name]: recoverySlice.reducer, }) const persistedSlices: (keyof PreloadedState)[] = [ diff --git a/src/store/recoverySlice.ts b/src/store/recoverySlice.ts new file mode 100644 index 0000000000..839b216497 --- /dev/null +++ b/src/store/recoverySlice.ts @@ -0,0 +1,21 @@ +import { createSelector } from '@reduxjs/toolkit' +import type { TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Delay' + +import { makeLoadableSlice } from './common' + +export type RecoveryState = Array<{ + address: string + modules: Array + txCooldown: string + txNonce: string + queueNonce: string + transactionsAdded: Array +}> + +const initialState: RecoveryState = [] + +const { slice, selector } = makeLoadableSlice('recovery', initialState) + +export const recoverySlice = slice + +export const selectRecovery = createSelector(selector, (recovery) => recovery.data) diff --git a/src/tests/builders/address-ex.ts b/src/tests/builders/address-ex.ts new file mode 100644 index 0000000000..3b90750db0 --- /dev/null +++ b/src/tests/builders/address-ex.ts @@ -0,0 +1,13 @@ +import { faker } from '@faker-js/faker' +import type { AddressEx } from '@safe-global/safe-gateway-typescript-sdk' + +import { Builder } from '../Builder' +import type { IBuilder } from '../Builder' + +export const addressExBuilder = (): IBuilder => { + return Builder.new().with({ + name: faker.word.words(), + value: faker.finance.ethereumAddress(), + logoUri: faker.image.url(), + }) +} diff --git a/src/tests/builders/safe.ts b/src/tests/builders/safe.ts new file mode 100644 index 0000000000..61c921e80d --- /dev/null +++ b/src/tests/builders/safe.ts @@ -0,0 +1,44 @@ +import { faker } from '@faker-js/faker' +import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import { generateRandomArray } from './utils' +import { addressExBuilder } from './address-ex' +import { LATEST_SAFE_VERSION } from '@/config/constants' +import { Builder } from '../Builder' +import type { IBuilder } from '../Builder' +import type useSafeInfo from '@/hooks/useSafeInfo' + +const OWNERS_LENGTH = 5 + +export const safeInfo = (): IBuilder => { + return Builder.new().with({ + address: addressExBuilder().build(), + chainId: faker.string.numeric({ exclude: '0' }), + nonce: faker.number.int(), + threshold: faker.number.int(), + owners: generateRandomArray(addressExBuilder().build, { min: 1, max: OWNERS_LENGTH }), + implementation: addressExBuilder().build(), + implementationVersionState: faker.helpers.enumValue(ImplementationVersionState), + modules: faker.helpers.arrayElement([generateRandomArray(addressExBuilder().build), null]), + guard: faker.helpers.arrayElement([addressExBuilder().build(), null]), + fallbackHandler: addressExBuilder().build(), + version: faker.helpers.arrayElement(['1.0.0', '1.1.1', '1.2.0', '1.3.0', LATEST_SAFE_VERSION]), + collectiblesTag: faker.string.numeric(), + txQueuedTag: faker.string.numeric(), + txHistoryTag: faker.string.numeric(), + messagesTag: faker.string.numeric(), + }) +} + +export const useSafeInfoBuilder = (): IBuilder> => { + const safe = safeInfo().build() + + return Builder.new>().with({ + safe, + safeAddress: safe.address.value, + safeLoaded: true, + safeError: undefined, + safeLoading: false, + }) +} diff --git a/yarn.lock b/yarn.lock index 978b89eee0..0f23fdb5d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2706,6 +2706,27 @@ dependencies: tslib "^2.1.0" +"@gnosis.pm/mock-contract@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@gnosis.pm/mock-contract/-/mock-contract-4.0.0.tgz#eaf500fddcab81b5f6c22280a7b22ff891dd6f87" + integrity sha512-SkRq2KwPx6vo0LAjSc8JhgQstrQFXRyn2yqquIfub7r2WHi5nUbF8beeSSXsd36hvBcQxQfmOIYNYRpj9JOhrQ== + +"@gnosis.pm/safe-contracts@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-contracts/-/safe-contracts-1.3.0.tgz#316741a7690d8751a1f701538cfc9ec80866eedc" + integrity sha512-1p+1HwGvxGUVzVkFjNzglwHrLNA67U/axP0Ct85FzzH8yhGJb4t9jDjPYocVMzLorDoWAfKicGy1akPY9jXRVw== + +"@gnosis.pm/zodiac@^3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@gnosis.pm/zodiac/-/zodiac-3.4.2.tgz#ce3e7498e39ccc3324eabc6f163bd173bf4d9aad" + integrity sha512-u7BPXsoo1ZdbmsElMbuejKNTWA3NPvFdzs3vjUSIcFfHTb9B/UE+gDQ3vMYL6bt+YLVw0F/IT5ytbiruKYQpEQ== + dependencies: + "@gnosis.pm/mock-contract" "^4.0.0" + "@gnosis.pm/safe-contracts" "1.3.0" + "@openzeppelin/contracts" "^5.0.0" + "@openzeppelin/contracts-upgradeable" "^5.0.0" + ethers "^5.7.1" + "@grpc/grpc-js@~1.9.0": version "1.9.5" resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.9.5.tgz#22e283754b7b10d1ad26c3fb21849028dcaabc53" @@ -3604,11 +3625,21 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@openzeppelin/contracts-upgradeable@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-5.0.0.tgz#859c00c55f04b6dda85b3c88bce507d65019888f" + integrity sha512-D54RHzkOKHQ8xUssPgQe2d/U92mwaiBDY7qCCVGq6VqwQjsT3KekEQ3bonev+BLP30oZ0R1U6YC8/oLpizgC5Q== + "@openzeppelin/contracts@^4.9.2": version "4.9.3" resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.3.tgz#00d7a8cf35a475b160b3f0293a6403c511099364" integrity sha512-He3LieZ1pP2TNt5JbkPA4PNT9WC3gOTOlDcFGJW4Le4QKqwmiNJCRt44APfxMxvq7OugU/cqYuPcSBzOw38DAg== +"@openzeppelin/contracts@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-5.0.0.tgz#ee0e4b4564f101a5c4ee398cd4d73c0bd92b289c" + integrity sha512-bv2sdS6LKqVVMLI5+zqnNrNU/CA+6z6CmwFXm/MzmOPBRSO5reEJN7z0Gbzvs0/bv/MZZXNklubpwy3v2+azsw== + "@polka/url@^1.0.0-next.20": version "1.0.0-next.23" resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.23.tgz#498e41218ab3b6a1419c735e5c6ae2c5ed609b6c" @@ -8798,7 +8829,7 @@ ethers@5.5.4: "@ethersproject/web" "5.5.1" "@ethersproject/wordlists" "5.5.0" -ethers@5.7.2: +ethers@5.7.2, ethers@^5.7.1: version "5.7.2" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" integrity sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==