Skip to content

Commit

Permalink
feat: Add support for proposing transactions as a delegate (#4017)
Browse files Browse the repository at this point in the history
* feat: Add support for proposing transactions as a delegate

* fix: failing unit test

* fix: Sign transaction to remove untrusted status

* fix: Only get delegate if on a safe

* fix: Allow signing with hardware wallet

* fix: Add getDelegates RTK Query

* refactor: Extract logic into dispatch call, add wallet rejection error

* chore: Update gateway-sdk package to support delegates v2 endpoint, switch chain before signing
  • Loading branch information
usame-algan authored Sep 3, 2024
1 parent d834e22 commit c209e2d
Show file tree
Hide file tree
Showing 16 changed files with 228 additions and 38 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"@safe-global/protocol-kit": "^4.1.0",
"@safe-global/safe-apps-sdk": "^9.1.0",
"@safe-global/safe-deployments": "^1.37.3",
"@safe-global/safe-gateway-typescript-sdk": "3.22.1",
"@safe-global/safe-gateway-typescript-sdk": "3.22.2",
"@safe-global/safe-modules-deployments": "^1.2.0",
"@sentry/react": "^7.91.0",
"@spindl-xyz/attribution-lite": "^1.4.0",
Expand Down
6 changes: 5 additions & 1 deletion src/components/common/CheckWallet/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useIsWalletDelegate } from '@/hooks/useDelegates'
import { type ReactElement } from 'react'
import { Tooltip } from '@mui/material'
import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary'
Expand All @@ -24,12 +25,15 @@ const CheckWallet = ({ children, allowSpendingLimit, allowNonOwner, noTooltip }:
const isSafeOwner = useIsSafeOwner()
const isSpendingLimit = useIsOnlySpendingLimitBeneficiary()
const connectWallet = useConnectWallet()
const isDelegate = useIsWalletDelegate()

const { safe } = useSafeInfo()
const isCounterfactualMultiSig = !allowNonOwner && !safe.deployed && safe.threshold > 1

const message =
wallet && (isSafeOwner || allowNonOwner || (isSpendingLimit && allowSpendingLimit)) && !isCounterfactualMultiSig
wallet &&
(isSafeOwner || allowNonOwner || (isSpendingLimit && allowSpendingLimit) || isDelegate) &&
!isCounterfactualMultiSig
? ''
: !wallet
? Message.WalletNotConnected
Expand Down
1 change: 1 addition & 0 deletions src/components/common/WalletProvider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const WalletProvider = ({ children }: { children: ReactNode }): ReactElement =>

const walletSubscription = onboard.state.select('wallets').subscribe((wallets) => {
const newWallet = getConnectedWallet(wallets)

setWallet(newWallet)
})

Expand Down
20 changes: 3 additions & 17 deletions src/components/settings/DelegatesList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,14 @@
import { getDelegates } from '@safe-global/safe-gateway-typescript-sdk'
import useAsync from '@/hooks/useAsync'
import useSafeInfo from '@/hooks/useSafeInfo'
import useDelegates from '@/hooks/useDelegates'
import { Box, Grid, Paper, SvgIcon, Tooltip, Typography } from '@mui/material'
import EthHashInfo from '@/components/common/EthHashInfo'
import InfoIcon from '@/public/images/notifications/info.svg'
import ExternalLink from '@/components/common/ExternalLink'
import { HelpCenterArticle } from '@/config/constants'

const useDelegates = () => {
const {
safe: { chainId },
safeAddress,
} = useSafeInfo()
const [delegates] = useAsync(() => {
if (!chainId || !safeAddress) return
return getDelegates(chainId, { safe: safeAddress })
}, [chainId, safeAddress])
return delegates
}

const DelegatesList = () => {
const delegates = useDelegates()

if (!delegates?.results.length) return null
if (!delegates.data?.results) return null

return (
<Paper sx={{ p: 4, mt: 2 }}>
Expand Down Expand Up @@ -55,7 +41,7 @@ const DelegatesList = () => {

<Grid item xs>
<ul style={{ padding: 0, margin: 0 }}>
{delegates.results.map((item) => (
{delegates.data.results.map((item) => (
<li
key={item.delegate}
style={{ listStyleType: 'none', marginBottom: '1em' }}
Expand Down
119 changes: 119 additions & 0 deletions src/components/tx/SignOrExecuteForm/DelegateForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import WalletRejectionError from '@/components/tx/SignOrExecuteForm/WalletRejectionError'
import { isWalletRejection } from '@/utils/wallets'
import { type ReactElement, type SyntheticEvent, useContext, useState } from 'react'
import { Box, Button, CardActions, CircularProgress, Divider } from '@mui/material'
import type { SafeTransaction } from '@safe-global/safe-core-sdk-types'
import CheckWallet from '@/components/common/CheckWallet'
import { TxModalContext } from '@/components/tx-flow'
import commonCss from '@/components/tx-flow/common/styles.module.css'
import ErrorMessage from '@/components/tx/ErrorMessage'
import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext'
import { useTxActions } from '@/components/tx/SignOrExecuteForm/hooks'
import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm/index'
import useWallet from '@/hooks/wallets/useWallet'
import { Errors, trackError } from '@/services/exceptions'
import { asError } from '@/services/exceptions/utils'
import madProps from '@/utils/mad-props'
import Stack from '@mui/system/Stack'

export const DelegateForm = ({
safeTx,
disableSubmit = false,
txActions,
txSecurity,
}: SignOrExecuteProps & {
txActions: ReturnType<typeof useTxActions>
txSecurity: ReturnType<typeof useTxSecurityContext>
safeTx?: SafeTransaction
}): ReactElement => {
// Form state
const [isSubmittable, setIsSubmittable] = useState<boolean>(true)
const [isRejectedByUser, setIsRejectedByUser] = useState<Boolean>(false)

// Hooks
const wallet = useWallet()
const { signDelegateTx } = txActions
const { setTxFlow } = useContext(TxModalContext)
const { needsRiskConfirmation, isRiskConfirmed, setIsRiskIgnored } = txSecurity

// On modal submit
const handleSubmit = async (e: SyntheticEvent) => {
e.preventDefault()

if (needsRiskConfirmation && !isRiskConfirmed) {
setIsRiskIgnored(true)
return
}

if (!safeTx || !wallet) return

setIsSubmittable(false)
setIsRejectedByUser(false)

try {
await signDelegateTx(safeTx)
} catch (_err) {
const err = asError(_err)
if (isWalletRejection(err)) {
setIsRejectedByUser(true)
} else {
trackError(Errors._805, err)
}
setIsSubmittable(true)
return
}

setTxFlow(undefined)
}

const submitDisabled = !safeTx || !isSubmittable || disableSubmit || (needsRiskConfirmation && !isRiskConfirmed)

return (
<form onSubmit={handleSubmit}>
<ErrorMessage level="info">
You are creating this transaction as a delegate. It will not have any signatures until it is confirmed by an
owner.
</ErrorMessage>

{isRejectedByUser && (
<Box mt={1}>
<WalletRejectionError />
</Box>
)}

<Divider className={commonCss.nestedDivider} sx={{ pt: 3 }} />

<CardActions>
<Stack
sx={{
width: ['100%', '100%', '100%', 'auto'],
}}
direction={{ xs: 'column-reverse', lg: 'row' }}
spacing={{ xs: 2, md: 2 }}
>
{/* Submit button */}
<CheckWallet>
{(isOk) => (
<Button
data-testid="sign-btn"
variant="contained"
type="submit"
disabled={!isOk || submitDisabled}
sx={{ minWidth: '82px', order: '1', width: ['100%', '100%', '100%', 'auto'] }}
>
{!isSubmittable ? <CircularProgress size={20} /> : 'Propose transaction'}
</Button>
)}
</CheckWallet>
</Stack>
</CardActions>
</form>
)
}

const useTxSecurityContext = () => useContext(TxSecurityContext)

export default madProps(DelegateForm, {
txActions: useTxActions,
txSecurity: useTxSecurityContext,
})
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('ExecuteForm', () => {
isOwner: true,
isExecutionLoop: false,
relays: [undefined, undefined, false] as AsyncResult<RelayCountResponse>,
txActions: { signTx: jest.fn(), addToBatch: jest.fn(), executeTx: jest.fn() },
txActions: { signTx: jest.fn(), addToBatch: jest.fn(), executeTx: jest.fn(), signDelegateTx: jest.fn() },
txSecurity: defaultSecurityContextValues,
}

Expand Down Expand Up @@ -105,7 +105,10 @@ describe('ExecuteForm', () => {
.mockReturnValue({ executionValidationError: new Error('Some error'), isValidExecutionLoading: false })

const { getByText } = render(
<ExecuteForm {...defaultProps} txActions={{ signTx: jest.fn(), addToBatch: jest.fn(), executeTx: jest.fn() }} />,
<ExecuteForm
{...defaultProps}
txActions={{ signTx: jest.fn(), addToBatch: jest.fn(), executeTx: jest.fn(), signDelegateTx: jest.fn() }}
/>,
)

expect(
Expand Down Expand Up @@ -135,7 +138,7 @@ describe('ExecuteForm', () => {
{...defaultProps}
safeTx={safeTransaction}
onSubmit={jest.fn()}
txActions={{ signTx: jest.fn(), addToBatch: jest.fn(), executeTx: mockExecuteTx }}
txActions={{ signTx: jest.fn(), addToBatch: jest.fn(), executeTx: mockExecuteTx, signDelegateTx: jest.fn() }}
/>,
)

Expand All @@ -155,7 +158,7 @@ describe('ExecuteForm', () => {
<ExecuteForm
{...defaultProps}
safeTx={safeTransaction}
txActions={{ signTx: jest.fn(), addToBatch: jest.fn(), executeTx: mockExecuteTx }}
txActions={{ signTx: jest.fn(), addToBatch: jest.fn(), executeTx: mockExecuteTx, signDelegateTx: jest.fn() }}
/>,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('SignForm', () => {
const defaultProps = {
onSubmit: jest.fn(),
isOwner: true,
txActions: { signTx: jest.fn(), addToBatch: jest.fn(), executeTx: jest.fn() },
txActions: { signTx: jest.fn(), addToBatch: jest.fn(), executeTx: jest.fn(), signDelegateTx: jest.fn() },
txSecurity: defaultSecurityContextValues,
}

Expand Down Expand Up @@ -70,7 +70,7 @@ describe('SignForm', () => {
<SignForm
{...defaultProps}
safeTx={safeTransaction}
txActions={{ signTx: mockSignTx, addToBatch: jest.fn(), executeTx: jest.fn() }}
txActions={{ signTx: mockSignTx, addToBatch: jest.fn(), executeTx: jest.fn(), signDelegateTx: jest.fn() }}
/>,
)

Expand All @@ -90,7 +90,7 @@ describe('SignForm', () => {
<SignForm
{...defaultProps}
safeTx={safeTransaction}
txActions={{ signTx: mockSignTx, addToBatch: jest.fn(), executeTx: jest.fn() }}
txActions={{ signTx: mockSignTx, addToBatch: jest.fn(), executeTx: jest.fn(), signDelegateTx: jest.fn() }}
/>,
)

Expand Down Expand Up @@ -133,7 +133,7 @@ describe('SignForm', () => {
safeTx={safeTransaction}
isBatchable
isCreation
txActions={{ signTx: jest.fn(), addToBatch: mockAddToBatch, executeTx: jest.fn() }}
txActions={{ signTx: jest.fn(), addToBatch: mockAddToBatch, executeTx: jest.fn(), signDelegateTx: jest.fn() }}
/>,
)

Expand Down
17 changes: 16 additions & 1 deletion src/components/tx/SignOrExecuteForm/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import useWallet from '@/hooks/wallets/useWallet'
import useOnboard from '@/hooks/wallets/useOnboard'
import { isSmartContractWallet } from '@/utils/wallets'
import {
dispatchDelegateTxSigning,
dispatchOnChainSigning,
dispatchTxExecution,
dispatchTxProposal,
Expand All @@ -31,6 +32,7 @@ type TxActions = {
origin?: string,
isRelayed?: boolean,
) => Promise<string>
signDelegateTx: (safeTx?: SafeTransaction) => Promise<string>
}

export const useTxActions = (): TxActions => {
Expand Down Expand Up @@ -98,6 +100,19 @@ export const useTxActions = (): TxActions => {
return tx.txId
}

const signDelegateTx: TxActions['signDelegateTx'] = async (safeTx) => {
assertTx(safeTx)
assertWallet(wallet)
assertOnboard(onboard)

await assertWalletChain(onboard, chainId)

const signedTx = await dispatchDelegateTxSigning(safeTx, wallet)

const tx = await proposeTx(wallet.address, signedTx)
return tx.txId
}

const executeTx: TxActions['executeTx'] = async (txOptions, safeTx, txId, origin, isRelayed) => {
assertTx(safeTx)
assertWallet(wallet)
Expand Down Expand Up @@ -137,7 +152,7 @@ export const useTxActions = (): TxActions => {
return txId
}

return { addToBatch, signTx, executeTx }
return { addToBatch, signTx, executeTx, signDelegateTx }
}, [safe, wallet, addTxToBatch, onboard, chain])
}

Expand Down
13 changes: 9 additions & 4 deletions src/components/tx/SignOrExecuteForm/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import DelegateForm from '@/components/tx/SignOrExecuteForm/DelegateForm'
import CounterfactualForm from '@/features/counterfactual/CounterfactualForm'
import { useIsWalletDelegate } from '@/hooks/useDelegates'
import useSafeInfo from '@/hooks/useSafeInfo'
import { type ReactElement, type ReactNode, useState, useContext, useCallback } from 'react'
import madProps from '@/utils/mad-props'
Expand Down Expand Up @@ -103,6 +105,7 @@ export const SignOrExecuteForm = ({
: skipToken,
)
const showTxDetails = props.txId && txDetails && !isCustomTxInfo(txDetails.txInfo)
const isDelegate = useIsWalletDelegate()
const [trigger] = useLazyGetTransactionDetailsQuery()
const [readableApprovals] = useApprovalInfos({ safeTransaction: safeTx })
const isApproval = readableApprovals && readableApprovals.length > 0
Expand Down Expand Up @@ -189,7 +192,7 @@ export const SignOrExecuteForm = ({
</ErrorMessage>
)}

{(canExecute || canExecuteThroughRole) && !props.onlyExecute && !isCounterfactualSafe && (
{(canExecute || canExecuteThroughRole) && !props.onlyExecute && !isCounterfactualSafe && !isDelegate && (
<ExecuteCheckbox onChange={setShouldExecute} />
)}

Expand All @@ -199,10 +202,10 @@ export const SignOrExecuteForm = ({

<RiskConfirmationError />

{isCounterfactualSafe && (
{isCounterfactualSafe && !isDelegate && (
<CounterfactualForm {...props} safeTx={safeTx} isCreation={isCreation} onSubmit={onFormSubmit} onlyExecute />
)}
{!isCounterfactualSafe && willExecute && (
{!isCounterfactualSafe && willExecute && !isDelegate && (
<ExecuteForm {...props} safeTx={safeTx} isCreation={isCreation} onSubmit={onFormSubmit} />
)}
{!isCounterfactualSafe && willExecuteThroughRole && (
Expand All @@ -214,7 +217,7 @@ export const SignOrExecuteForm = ({
role={(allowingRole || mostLikelyRole)!}
/>
)}
{!isCounterfactualSafe && !willExecute && !willExecuteThroughRole && (
{!isCounterfactualSafe && !willExecute && !willExecuteThroughRole && !isDelegate && (
<SignForm
{...props}
safeTx={safeTx}
Expand All @@ -223,6 +226,8 @@ export const SignOrExecuteForm = ({
onSubmit={onFormSubmit}
/>
)}

{isDelegate && <DelegateForm {...props} safeTx={safeTx} onSubmit={onFormSubmit} />}
</TxCard>
</>
)
Expand Down
22 changes: 22 additions & 0 deletions src/hooks/useDelegates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import useSafeInfo from '@/hooks/useSafeInfo'
import useWallet from '@/hooks/wallets/useWallet'
import { useGetDelegatesQuery } from '@/store/gateway'
import { skipToken } from '@reduxjs/toolkit/query/react'

const useDelegates = () => {
const {
safe: { chainId },
safeAddress,
} = useSafeInfo()

return useGetDelegatesQuery(chainId && safeAddress ? { chainId, safeAddress } : skipToken)
}

export const useIsWalletDelegate = () => {
const wallet = useWallet()
const delegates = useDelegates()

return delegates.data?.results.some((delegate) => delegate.delegate === wallet?.address)
}

export default useDelegates
Loading

0 comments on commit c209e2d

Please sign in to comment.