Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PoC: recovery via Delay Modifier #2537

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,12 @@
},
"dependencies": {
"@date-io/date-fns": "^2.15.0",
"@ducanh2912/next-pwa": "^9.5.0",
"@ducanh2912/next-pwa": "9.5.0",
"@emotion/cache": "^11.10.1",
"@emotion/react": "^11.10.0",
"@emotion/server": "^11.10.0",
"@emotion/styled": "^11.10.0",
"@gnosis.pm/zodiac": "^3.3.7",
"@mui/icons-material": "^5.14.3",
"@mui/material": "^5.14.3",
"@mui/x-date-pickers": "^5.0.12",
Expand Down
95 changes: 95 additions & 0 deletions src/components/settings/Recovery/RecoverersList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import EthHashInfo from '@/components/common/EthHashInfo'
import { Grid, Typography, Button, SvgIcon, Tooltip, IconButton } from '@mui/material'
import { useContext, useMemo } from 'react'
import type { Delay } from '@gnosis.pm/zodiac'
import type { ReactElement } from 'react'

import EnhancedTable from '@/components/common/EnhancedTable'
import AddIcon from '@/public/images/common/add.svg'
import CheckWallet from '@/components/common/CheckWallet'
import DeleteIcon from '@/public/images/common/delete.svg'
import { AddRecoverer } from '@/components/tx-flow/flows/AddRecoverer'
import { TxModalContext } from '@/components/tx-flow'
import { RemoveRecoverer } from '@/components/tx-flow/flows/RemoveRecoverer'

import tableCss from '@/components/common/EnhancedTable/styles.module.css'

const headCells = [
{ id: 'owner', label: 'Recoverer' },
{ id: 'actions', label: '', sticky: true },
]

export const RecoverersList = ({
delayModifier,
recoverers,
}: {
delayModifier: Delay
recoverers: Array<string>
}): ReactElement => {
const { setTxFlow } = useContext(TxModalContext)

const rows = useMemo(() => {
return recoverers.map((recoverer) => {
return {
cells: {
owner: {
rawValue: recoverer,
content: (
<EthHashInfo address={recoverer} showCopyButton shortAddress={false} showName={true} hasExplorer />
),
},
actions: {
rawValue: '',
sticky: true,
content: (
<div className={tableCss.actions}>
<CheckWallet>
{(isOk) => (
<Tooltip title="Remove recoverer">
<IconButton
onClick={() =>
setTxFlow(<RemoveRecoverer delayModifier={delayModifier} recoverer={recoverer} />)
}
size="small"
disabled={!isOk}
>
<SvgIcon component={DeleteIcon} inheritViewBox color="error" fontSize="small" />
</IconButton>
</Tooltip>
)}
</CheckWallet>
</div>
),
},
},
}
})
}, [delayModifier, recoverers, setTxFlow])

return (
<Grid container spacing={3}>
<Grid item lg={4} xs={12}>
<Typography variant="h4" fontWeight={700}>
Manage recoverers
</Typography>
</Grid>

<Grid item xs>
<EnhancedTable rows={rows ?? []} headCells={headCells} />

<CheckWallet>
{(isOk) => (
<Button
onClick={() => setTxFlow(<AddRecoverer delayModifier={delayModifier} />)}
variant="text"
startIcon={<SvgIcon component={AddIcon} inheritViewBox fontSize="small" />}
disabled={!isOk}
>
Add new recoverer
</Button>
)}
</CheckWallet>
</Grid>
</Grid>
)
}
46 changes: 46 additions & 0 deletions src/components/settings/Recovery/RecoveryProposal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Button } from '@mui/material'
import { Operation } from '@safe-global/safe-gateway-typescript-sdk'
import type { Delay } from '@gnosis.pm/zodiac'
import type { ReactElement } from 'react'

import useWallet from '@/hooks/wallets/useWallet'
import { getSafeContractDeployment } from '@/services/contracts/deployments'
import { useCurrentChain } from '@/hooks/useChains'
import useSafeInfo from '@/hooks/useSafeInfo'
import { Interface } from 'ethers/lib/utils'
import { useWeb3 } from '@/hooks/wallets/web3'

const NEW_THRESHOLD = 1

export function RecoveryProposal({ delayModifier }: { delayModifier: Delay }): ReactElement {
const chain = useCurrentChain()
const { safe, safeAddress } = useSafeInfo()
const wallet = useWallet()
const web3 = useWeb3()

const onPropose = async () => {
if (!chain || !wallet || !web3) {
return
}

const safeDeployment = getSafeContractDeployment(chain, safe.version)
const safeInterface = new Interface(safeDeployment?.abi ?? [])

const addOwnerWithThresholdData = safeInterface.encodeFunctionData('addOwnerWithThreshold', [
wallet.address,
NEW_THRESHOLD,
])

const signer = web3.getSigner()

await delayModifier
.connect(signer)
.execTransactionFromModule(safeAddress, '0', addOwnerWithThresholdData, Operation.CALL)
}

return (
<Button variant="contained" onClick={onPropose}>
Propose recovery
</Button>
)
}
127 changes: 127 additions & 0 deletions src/components/settings/Recovery/RecoveryProposals.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { Grid, IconButton, SvgIcon, Tooltip, Typography } from '@mui/material'
import { Interface } from 'ethers/lib/utils'
import { useContext } from 'react'
import type { ReactElement } from 'react'
import type { Delay } from '@gnosis.pm/zodiac'
import type { TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Delay'

import RocketIcon from '@/public/images/transactions/rocket.svg'
import ErrorIcon from '@/public/images/notifications/error.svg'
import { useDelayModifierQueue } from './useDelayModifierQueue'
import { getSafeContractDeployment } from '@/services/contracts/deployments'
import { useCurrentChain } from '@/hooks/useChains'
import useSafeInfo from '@/hooks/useSafeInfo'
import { memoize } from 'lodash'
import { useWeb3 } from '@/hooks/wallets/web3'
import CheckWallet from '@/components/common/CheckWallet'
import { CancelRecovery } from '@/components/tx-flow/flows/CancelRecovery'
import { TxModalContext } from '@/components/tx-flow'

function getSigHash(data: string) {
return data.substring(0, 10)
}

const getFunctionFragment = memoize(
(safeInterface: Interface, data: string) => {
const sigHash = getSigHash(data)

return Object.values(safeInterface.functions).find((fn) => {
return sigHash === safeInterface.getSighash(fn)
})
},
(_, data) => getSigHash(data),
)

export function RecoveryProposals({
delayModifier,
isRecoverer,
}: {
delayModifier: Delay
isRecoverer?: boolean
}): ReactElement | null {
const { setTxFlow } = useContext(TxModalContext)
const [queue] = useDelayModifierQueue(delayModifier)
const chain = useCurrentChain()
const { safe } = useSafeInfo()
const web3 = useWeb3()

if (!chain || !queue) {
return null
}

const safeInterface = new Interface(getSafeContractDeployment(chain, safe.version)?.abi ?? [])

const onExecute = async (event: TransactionAddedEvent) => {
if (!web3) {
return
}

const { to, value, data, operation } = event.args

const signer = web3.getSigner()

await delayModifier.connect(signer).executeNextTx(to, value, data, operation)
}

const onCancel = async (event: TransactionAddedEvent) => {
setTxFlow(<CancelRecovery delayModifier={delayModifier} recovery={event} />)
}

return (
<Grid container spacing={3}>
<Grid item lg={4} xs={12}>
<Typography variant="h4" fontWeight={700}>
Recovery proposals
</Typography>
</Grid>

<Grid item xs>
<ol>
{queue.map((item) => {
const queuedNonce = item.args.queueNonce.toString()

const functionFragment = getFunctionFragment(safeInterface, item.args.data)
const args = functionFragment ? safeInterface.decodeFunctionData(functionFragment, item.args.data) : []

const formattedMethod = functionFragment
? `${functionFragment.name}(${functionFragment.inputs
.map(({ name }, i) => `${name}: ${args[i]}`)
.join(', ')})`
: 'Unknown'

return (
<li key={queuedNonce} value={queuedNonce}>
{formattedMethod}{' '}
{isRecoverer ? (
<CheckWallet allowNonOwner>
{(isOk) => (
<Tooltip title={isOk ? 'Recover Safe Account' : undefined} arrow placement="top">
<span>
<IconButton onClick={() => onExecute(item)} color="primary" size="small" disabled={!isOk}>
<SvgIcon component={RocketIcon} inheritViewBox fontSize="small" />
</IconButton>
</span>
</Tooltip>
)}
</CheckWallet>
) : (
<CheckWallet>
{(isOk) => (
<Tooltip title={isOk ? 'Cancel recovery' : undefined} arrow placement="top">
<span>
<IconButton onClick={() => onCancel(item)} color="error" size="small" disabled={!isOk}>
<SvgIcon component={ErrorIcon} inheritViewBox fontSize="small" />
</IconButton>
</span>
</Tooltip>
)}
</CheckWallet>
)}
</li>
)
})}
</ol>
</Grid>
</Grid>
)
}
89 changes: 89 additions & 0 deletions src/components/settings/Recovery/RecoverySetup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useContext } from 'react'
import { Button, Grid, IconButton, SvgIcon, TextField, Typography } from '@mui/material'
import { useForm, useFieldArray, Controller } from 'react-hook-form'
import type { ReactElement } from 'react'

import CheckWallet from '@/components/common/CheckWallet'
import { EnableRecovery } from '@/components/tx-flow/flows/EnableRecovery'
import { TxModalContext } from '@/components/tx-flow'
import AddIcon from '@/public/images/common/add.svg'
import DeleteIcon from '@/public/images/common/delete.svg'

const enum Fields {
RECOVERERS = 'recoverers',
}

// RHF does not support primitive arrays so we need to use an object
type Form = {
[Fields.RECOVERERS]: Array<{ address: string }>
}

export const RecoverySetup = (): ReactElement => {
const { setTxFlow } = useContext(TxModalContext)

const { handleSubmit, control } = useForm<Form>({
mode: 'all',
defaultValues: {
recoverers: [{ address: '' }],
},
})

const { fields, remove, append } = useFieldArray({
name: Fields.RECOVERERS,
control,
})

const onEnable = (data: Form) => {
const recoverers = data[Fields.RECOVERERS].map(({ address }) => address)

setTxFlow(<EnableRecovery recoverers={recoverers} />)
}
return (
<Grid container direction="row" justifyContent="space-between" spacing={3} mb={2}>
<Grid item lg={4} xs={12}>
<Typography variant="h4" fontWeight={700}>
Recovery
</Typography>
</Grid>

<Grid item xs>
<form onSubmit={handleSubmit(onEnable)}>
{fields.map((item, i) => (
<>
<Controller
key={item.id}
name={`${Fields.RECOVERERS}.${i}.address`}
control={control}
render={({ field, fieldState }) => (
<TextField label={`Recoverer ${i + 1}`} error={!!fieldState.error} {...field} fullWidth />
)}
/>
{i > 0 && (
<IconButton onClick={() => remove(i)} aria-label="Remove recoverer">
<SvgIcon component={DeleteIcon} inheritViewBox />
</IconButton>
)}
</>
))}

<Button
variant="text"
onClick={() => append({ address: '' }, { shouldFocus: true })}
startIcon={<SvgIcon component={AddIcon} inheritViewBox fontSize="small" />}
size="large"
>
Add new owner
</Button>

<CheckWallet>
{(isOk) => (
<Button type="submit" variant="contained" disabled={!isOk}>
Enable
</Button>
)}
</CheckWallet>
</form>
</Grid>
</Grid>
)
}
Loading
Loading