Skip to content

Commit

Permalink
feat: poll recovery state of Safe
Browse files Browse the repository at this point in the history
  • Loading branch information
iamacook committed Nov 7, 2023
1 parent 2fce8de commit 2cecd77
Show file tree
Hide file tree
Showing 10 changed files with 275 additions and 1 deletion.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions src/hooks/loadables/useLoadRecovery.ts
Original file line number Diff line number Diff line change
@@ -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<RecoveryState[number]> => {
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<RecoveryState> => {
const { safe } = useSafeInfo()
const web3ReadOnly = useWeb3ReadOnly()
const [counter] = useIntervalCounter(REFRESH_DELAY)

const [delayModifiers, delayModifiersError, delayModifiersLoading] = useAsync<Array<Delay>>(() => {
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<RecoveryState>(() => {
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
3 changes: 3 additions & 0 deletions src/hooks/useLoadableStores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<unknown>): void => {
Expand All @@ -46,6 +48,7 @@ const useLoadableStores = () => {
useUpdateStore(txQueueSlice, useLoadTxQueue)
useUpdateStore(safeMessagesSlice, useLoadMessages)
useUpdateStore(spendingLimitSlice, useLoadSpendingLimits)
useUpdateStore(recoverySlice, useLoadRecovery)
}

export default useLoadableStores
58 changes: 58 additions & 0 deletions src/services/recovery/delay-modifier.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<Array<Delay>> {
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<Delay>
}
30 changes: 30 additions & 0 deletions src/services/recovery/proxies.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const gnosisProxyAbi = ['function masterCopy() external view returns (address)']
const gnosisProxyContract = new Contract(address, gnosisProxyAbi, provider)

const [masterCopy] = await gnosisProxyContract.masterCopy()

return masterCopy
}
2 changes: 2 additions & 0 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<RootState>)[] = [
Expand Down
21 changes: 21 additions & 0 deletions src/store/recoverySlice.ts
Original file line number Diff line number Diff line change
@@ -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<string>
txCooldown: string
txNonce: string
queueNonce: string
transactionsAdded: Array<TransactionAddedEvent>
}>

const initialState: RecoveryState = []

const { slice, selector } = makeLoadableSlice('recovery', initialState)

export const recoverySlice = slice

export const selectRecovery = createSelector(selector, (recovery) => recovery.data)
13 changes: 13 additions & 0 deletions src/tests/builders/address-ex.ts
Original file line number Diff line number Diff line change
@@ -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<AddressEx> => {
return Builder.new<AddressEx>().with({
name: faker.word.words(),
value: faker.finance.ethereumAddress(),
logoUri: faker.image.url(),
})
}
44 changes: 44 additions & 0 deletions src/tests/builders/safe.ts
Original file line number Diff line number Diff line change
@@ -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<SafeInfo> => {
return Builder.new<SafeInfo>().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<ReturnType<typeof useSafeInfo>> => {
const safe = safeInfo().build()

return Builder.new<ReturnType<typeof useSafeInfo>>().with({
safe,
safeAddress: safe.address.value,
safeLoaded: true,
safeError: undefined,
safeLoading: false,
})
}
33 changes: 32 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]":
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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -8798,7 +8829,7 @@ [email protected]:
"@ethersproject/web" "5.5.1"
"@ethersproject/wordlists" "5.5.0"

[email protected]:
[email protected], ethers@^5.7.1:
version "5.7.2"
resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e"
integrity sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==
Expand Down

0 comments on commit 2cecd77

Please sign in to comment.