From 2ecdfefeea8ca7a8860e6898b9d2416d6662803c Mon Sep 17 00:00:00 2001 From: Jan-Felix <524089+jfschwarz@users.noreply.github.com> Date: Wed, 5 Jun 2024 14:10:14 +0200 Subject: [PATCH] feat: Execute transaction through role (#3768) --- package.json | 5 +- .../flows/SuccessScreen/StatusStepper.tsx | 2 +- .../tx-flow/flows/SuccessScreen/index.tsx | 30 +- .../__test__/PermissionsCheck.test.tsx | 269 ++++++++++++++++++ .../PermissionsCheck/hooks.ts | 264 +++++++++++++++++ .../PermissionsCheck/index.tsx | 220 ++++++++++++++ src/components/tx/SignOrExecuteForm/index.tsx | 7 + src/services/analytics/events/transactions.ts | 5 + src/services/analytics/types.ts | 1 + src/services/exceptions/ErrorCodes.ts | 1 + src/services/transactions/index.ts | 29 ++ src/services/tx/tx-sender/dispatch.ts | 55 +++- yarn.lock | 5 + 13 files changed, 879 insertions(+), 14 deletions(-) create mode 100644 src/components/tx/SignOrExecuteForm/PermissionsCheck/__test__/PermissionsCheck.test.tsx create mode 100644 src/components/tx/SignOrExecuteForm/PermissionsCheck/hooks.ts create mode 100644 src/components/tx/SignOrExecuteForm/PermissionsCheck/index.tsx diff --git a/package.json b/package.json index ea0a77454e..e38f308cc7 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,8 @@ "react-hook-form": "7.41.1", "react-papaparse": "^4.0.2", "react-redux": "^8.0.5", - "semver": "^7.5.2" + "semver": "^7.5.2", + "zodiac-roles-deployments": "^2.2.2" }, "devDependencies": { "@chromatic-com/storybook": "^1.3.1", @@ -160,4 +161,4 @@ "minimumChangeThreshold": 0, "showDetails": true } -} +} \ No newline at end of file diff --git a/src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx b/src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx index 5852deda79..6952955195 100644 --- a/src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx +++ b/src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx @@ -5,7 +5,7 @@ import StatusStep from '@/components/new-safe/create/steps/StatusStep/StatusStep import useSafeInfo from '@/hooks/useSafeInfo' import { PendingStatus } from '@/store/pendingTxsSlice' -const StatusStepper = ({ status, txHash }: { status: PendingStatus; txHash?: string }) => { +const StatusStepper = ({ status, txHash }: { status?: PendingStatus; txHash?: string }) => { const { safeAddress } = useSafeInfo() const isProcessing = status === PendingStatus.PROCESSING || status === PendingStatus.INDEXING || status === undefined diff --git a/src/components/tx-flow/flows/SuccessScreen/index.tsx b/src/components/tx-flow/flows/SuccessScreen/index.tsx index f11a348087..c853ded2f6 100644 --- a/src/components/tx-flow/flows/SuccessScreen/index.tsx +++ b/src/components/tx-flow/flows/SuccessScreen/index.tsx @@ -19,24 +19,33 @@ import useDecodeTx from '@/hooks/useDecodeTx' import { isSwapConfirmationViewOrder } from '@/utils/transaction-guards' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -const SuccessScreen = ({ txId, safeTx }: { txId: string; safeTx?: SafeTransaction }) => { - const [localTxHash, setLocalTxHash] = useState() +interface Props { + /** The ID assigned to the transaction in the client-gateway */ + txId?: string + /** For module transaction, pass the transaction hash while the `txId` is not yet available */ + txHash?: string + /** The multisig transaction object */ + safeTx?: SafeTransaction +} + +const SuccessScreen = ({ txId, txHash, safeTx }: Props) => { + const [localTxHash, setLocalTxHash] = useState(txHash) const [error, setError] = useState() const { setTxFlow } = useContext(TxModalContext) const chain = useCurrentChain() - const pendingTx = useAppSelector((state) => selectPendingTxById(state, txId)) + const pendingTx = useAppSelector((state) => (txId ? selectPendingTxById(state, txId) : undefined)) const { safeAddress } = useSafeInfo() - const { status } = pendingTx || {} - const txHash = pendingTx && 'txHash' in pendingTx ? pendingTx.txHash : undefined - const txLink = chain && getTxLink(txId, chain, safeAddress) + const status = !txId && txHash ? PendingStatus.INDEXING : pendingTx?.status + const pendingTxHash = pendingTx && 'txHash' in pendingTx ? pendingTx.txHash : undefined + const txLink = chain && txId && getTxLink(txId, chain, safeAddress) const [decodedData] = useDecodeTx(safeTx) const isSwapOrder = isSwapConfirmationViewOrder(decodedData) useEffect(() => { - if (!txHash) return + if (!pendingTxHash) return - setLocalTxHash(txHash) - }, [txHash]) + setLocalTxHash(pendingTxHash) + }, [pendingTxHash]) useEffect(() => { const unsubFns: Array<() => void> = ([TxEvent.FAILED, TxEvent.REVERTED] as const).map((event) => @@ -59,7 +68,8 @@ const SuccessScreen = ({ txId, safeTx }: { txId: string; safeTx?: SafeTransactio switch (status) { case PendingStatus.PROCESSING: case PendingStatus.RELAYING: - StatusComponent = + // status can only have these values if txId & pendingTx are defined + StatusComponent = break case PendingStatus.INDEXING: StatusComponent = diff --git a/src/components/tx/SignOrExecuteForm/PermissionsCheck/__test__/PermissionsCheck.test.tsx b/src/components/tx/SignOrExecuteForm/PermissionsCheck/__test__/PermissionsCheck.test.tsx new file mode 100644 index 0000000000..48063d0b93 --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/PermissionsCheck/__test__/PermissionsCheck.test.tsx @@ -0,0 +1,269 @@ +import { createMockSafeTransaction } from '@/tests/transactions' +import { OperationType } from '@safe-global/safe-core-sdk-types' +import { type ReactElement } from 'react' +import * as zodiacRoles from 'zodiac-roles-deployments' +import { fireEvent, render, waitFor, mockWeb3Provider } from '@/tests/test-utils' + +import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' +import * as useSafeInfoHook from '@/hooks/useSafeInfo' +import * as wallet from '@/hooks/wallets/useWallet' +import * as onboardHooks from '@/hooks/wallets/useOnboard' +import * as txSender from '@/services/tx/tx-sender/dispatch' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import { type OnboardAPI } from '@web3-onboard/core' +import { AbiCoder, ZeroAddress, encodeBytes32String } from 'ethers' +import PermissionsCheck from '..' +import * as hooksModule from '../hooks' + +// We assume that CheckWallet always returns true +jest.mock('@/components/common/CheckWallet', () => ({ + __esModule: true, + default({ children }: { children: (ok: boolean) => ReactElement }) { + return children(true) + }, +})) + +// mock useCurrentChain & useHasFeature +jest.mock('@/hooks/useChains', () => ({ + useCurrentChain: jest.fn(() => ({ + shortName: 'eth', + chainId: '1', + chainName: 'Ethereum', + features: [], + transactionService: 'https://tx.service.mock', + })), + useHasFeature: jest.fn(() => true), // used to check for EIP1559 support +})) + +// mock getModuleTransactionId +jest.mock('@/services/transactions', () => ({ + getModuleTransactionId: jest.fn(() => 'i1234567890'), +})) + +describe('PermissionsCheck', () => { + let executeSpy: jest.SpyInstance + let fetchRolesModMock: jest.SpyInstance + + const mockConnectedWalletAddress = (address: string) => { + // Onboard + jest.spyOn(onboardHooks, 'default').mockReturnValue({ + setChain: jest.fn(), + state: { + get: () => ({ + wallets: [ + { + label: 'MetaMask', + accounts: [{ address }], + connected: true, + chains: [{ id: '1' }], + }, + ], + }), + }, + } as unknown as OnboardAPI) + + // Wallet + jest.spyOn(wallet, 'default').mockReturnValue({ + chainId: '1', + label: 'MetaMask', + address, + } as unknown as ConnectedWallet) + } + + beforeEach(() => { + jest.clearAllMocks() + + // Safe info + jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ + safe: SAFE_INFO, + safeAddress: SAFE_INFO.address.value, + safeError: undefined, + safeLoading: false, + safeLoaded: true, + })) + + // Roles mod fetching + + // Mock the Roles mod fetching function to return the test roles mod + + fetchRolesModMock = jest.spyOn(zodiacRoles, 'fetchRolesMod').mockReturnValue(Promise.resolve(TEST_ROLES_MOD as any)) + + // Mock signing and dispatching the module transaction + executeSpy = jest + .spyOn(txSender, 'dispatchModuleTxExecution') + .mockReturnValue(Promise.resolve('0xabababababababababababababababababababababababababababababababab')) // tx hash + + // Mock return value of useWeb3ReadOnly + // It's only used for eth_estimateGas requests + mockWeb3Provider([]) + + jest.spyOn(hooksModule, 'pollModuleTransactionId').mockReturnValue(Promise.resolve('i1234567890')) + }) + + it('only shows the card when the user is a member of any role', async () => { + mockConnectedWalletAddress(SAFE_INFO.owners[0].value) // connect as safe owner (not a role member) + + const safeTx = createMockSafeTransaction({ + to: ZeroAddress, + data: '0xd0e30db0', // deposit() + value: AbiCoder.defaultAbiCoder().encode(['uint256'], [123]), + operation: OperationType.Call, + }) + + const { queryByText } = render() + + // wait for the Roles mod to be fetched + await waitFor(() => { + expect(fetchRolesModMock).toBeCalled() + }) + + // the card is not shown + expect(queryByText('Execute without confirmations')).not.toBeInTheDocument() + }) + + it('disables the submit button when the call is not allowed and shows the permission check status', async () => { + mockConnectedWalletAddress(MEMBER_ADDRESS) + + const safeTx = createMockSafeTransaction({ + to: ZeroAddress, + data: '0xd0e30db0', // deposit() + value: AbiCoder.defaultAbiCoder().encode(['uint256'], [123]), + operation: OperationType.Call, + }) + + const { findByText, getByText } = render() + expect(await findByText('Execute')).toBeDisabled() + + expect( + getByText( + textContentMatcher('You are a member of the eth_wrapping role but it does not allow this transaction.'), + ), + ).toBeInTheDocument() + + expect(getByText('TargetAddressNotAllowed')).toBeInTheDocument() + }) + + it('execute the tx when the submit button is clicked', async () => { + mockConnectedWalletAddress(MEMBER_ADDRESS) + + const safeTx = createMockSafeTransaction({ + to: WETH_ADDRESS, + data: '0xd0e30db0', // deposit() + value: AbiCoder.defaultAbiCoder().encode(['uint256'], [123]), + operation: OperationType.Call, + }) + + const onSubmit = jest.fn() + + const { findByText } = render() + + fireEvent.click(await findByText('Execute')) + + await waitFor(() => { + expect(executeSpy).toHaveBeenCalledWith( + // call to the Roles mod's execTransactionWithRole function + expect.objectContaining({ + to: TEST_ROLES_MOD.address, + data: '0xc6fe8747000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000000000000000000000000000000000000000007b00000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000006574685f7772617070696e67000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000004d0e30db000000000000000000000000000000000000000000000000000000000', + value: '0', + }), + undefined, + expect.anything(), + ) + }) + + // calls provided onSubmit callback + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + }) + }) +}) + +const ROLES_MOD_ADDRESS = '0x1234567890000000000000000000000000000000' +const MEMBER_ADDRESS = '0x1111111110000000000000000000000000000000' +const ROLE_KEY = encodeBytes32String('eth_wrapping') + +const SAFE_INFO = extendedSafeInfoBuilder().build() +SAFE_INFO.modules = [{ value: ROLES_MOD_ADDRESS }] +SAFE_INFO.chainId = '1' + +const lowercaseSafeAddress = SAFE_INFO.address.value.toLowerCase() + +const WETH_ADDRESS = '0xfff9976782d46cc05630d1f6ebab18b2324d6b14' + +const { Clearance, ExecutionOptions } = zodiacRoles + +const TEST_ROLES_MOD = { + address: ROLES_MOD_ADDRESS, + owner: lowercaseSafeAddress, + avatar: lowercaseSafeAddress, + target: lowercaseSafeAddress, + roles: [ + { + key: ROLE_KEY, + members: [MEMBER_ADDRESS], + targets: [ + { + address: '0xc36442b4a4522e871399cd717abdd847ab11fe88', + clearance: Clearance.Function, + executionOptions: ExecutionOptions.None, + functions: [ + { + selector: '0x49404b7c', + wildcarded: false, + executionOptions: ExecutionOptions.None, + }, + ], + }, + { + address: WETH_ADDRESS, // WETH + clearance: Clearance.Function, + executionOptions: ExecutionOptions.None, + functions: [ + { + selector: '0x2e1a7d4d', // withdraw(uint256) + wildcarded: true, + executionOptions: ExecutionOptions.None, + }, + { + selector: '0xd0e30db0', // deposit() + wildcarded: true, + executionOptions: ExecutionOptions.Send, + }, + ], + }, + ], + }, + ], +} + +/** + * Getting the deepest element that contain string / match regex even when it split between multiple elements + * + * @example + * For: + *
+ * Hello World + *
+ * + * screen.getByText('Hello World') // ❌ Fail + * screen.getByText(textContentMatcher('Hello World')) // ✅ pass + */ +function textContentMatcher(textMatch: string | RegExp) { + const hasText = + typeof textMatch === 'string' + ? (node: Element) => node.textContent === textMatch + : (node: Element) => textMatch.test(node.textContent || '') + + const matcher = (_content: string, node: Element | null) => { + if (!node || !hasText(node)) { + return false + } + + return Array.from(node?.children || []).every((child) => !hasText(child)) + } + + matcher.toString = () => `textContentMatcher(${textMatch})` + + return matcher +} diff --git a/src/components/tx/SignOrExecuteForm/PermissionsCheck/hooks.ts b/src/components/tx/SignOrExecuteForm/PermissionsCheck/hooks.ts new file mode 100644 index 0000000000..18e07f5af7 --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/PermissionsCheck/hooks.ts @@ -0,0 +1,264 @@ +import useAsync from '@/hooks/useAsync' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useWeb3ReadOnly } from '@/hooks/wallets/web3' +import { Errors, logError } from '@/services/exceptions' +import { getModuleTransactionId } from '@/services/transactions' +import { backOff } from 'exponential-backoff' +import { useEffect, useMemo } from 'react' +import { + type ChainId, + chains, + fetchRolesMod, + Clearance, + type RoleSummary, + ExecutionOptions, + Status, +} from 'zodiac-roles-deployments' +import { OperationType, type Transaction, type MetaTransactionData } from '@safe-global/safe-core-sdk-types' +import { type JsonRpcProvider } from 'ethers' +import { KnownContracts, getModuleInstance } from '@gnosis.pm/zodiac' +import useWallet from '@/hooks/wallets/useWallet' + +const ROLES_V2_SUPPORTED_CHAINS = Object.keys(chains) + +/** + * Returns all Zodiac Roles Modifiers v2 instances that are enabled and correctly configured on this Safe + */ +export const useRolesMods = () => { + const { safe } = useSafeInfo() + + const [data] = useAsync(async () => { + if (!ROLES_V2_SUPPORTED_CHAINS.includes(safe.chainId)) return [] + + const safeModules = safe.modules || [] + const rolesMods = await Promise.all( + safeModules.map((address) => + fetchRolesMod({ address: address.value as `0x${string}`, chainId: parseInt(safe.chainId) as ChainId }), + ), + ) + + return rolesMods.filter( + (mod): mod is Exclude => + mod !== null && + mod.target === safe.address.value.toLowerCase() && + mod.avatar === safe.address.value.toLowerCase() && + mod.roles.length > 0, + ) + }, [safe]) + + return data +} + +/** + * Returns a list of roles mod address + role key assigned to the connected wallet. + * For each role, checks if the role allows the given meta transaction and returns the status. + */ +export const useRoles = (metaTx?: MetaTransactionData) => { + const rolesMods = useRolesMods() + const wallet = useWallet() + const walletAddress = wallet?.address.toLowerCase() as undefined | `0x${string}` + + // find all roles assigned to the connected wallet, statically check if they allow the given meta transaction + const potentialRoles = useMemo(() => { + const result: { + modAddress: `0x${string}` + roleKey: `0x${string}` + status: Status | null + }[] = [] + + if (walletAddress && rolesMods) { + for (const rolesMod of rolesMods) { + for (const role of rolesMod.roles) { + if (role.members.includes(walletAddress)) { + result.push({ + modAddress: rolesMod.address, + roleKey: role.key, + status: metaTx ? checkTransaction(role, metaTx) : null, + }) + } + } + } + } + + return result + }, [rolesMods, walletAddress, metaTx]) + const web3ReadOnly = useWeb3ReadOnly() + + // if the static check is inconclusive (status: null), evaluate the condition through a test call + const [dynamicallyCheckedPotentialRoles] = useAsync( + () => + Promise.all( + potentialRoles.map(async (entry) => { + if (entry.status === null && metaTx && walletAddress && web3ReadOnly) { + entry.status = await checkCondition(entry.modAddress, entry.roleKey, metaTx, walletAddress, web3ReadOnly) + } + return entry + }), + ), + [potentialRoles, metaTx, walletAddress, web3ReadOnly], + ) + + // Return the statically checked roles while the dynamic checks are still pending + return dynamicallyCheckedPotentialRoles || potentialRoles +} + +/** + * Returns the status of the permission check, `null` if it depends on the condition evaluation. + */ +const checkTransaction = (role: RoleSummary, metaTx: MetaTransactionData): Status | null => { + const target = role.targets.find((t) => t.address === metaTx.to.toLowerCase()) + if (!target) return Status.TargetAddressNotAllowed + + if (target.clearance === Clearance.Target) { + // all calls to the target are allowed + return checkExecutionOptions(target.executionOptions, metaTx) + } + + if (target.clearance === Clearance.Function) { + // check if the function is allowed + const selector = metaTx.data.slice(0, 10) as `0x${string}` + const func = target.functions.find((f) => f.selector === selector) + if (func) { + const execOptionsStatus = checkExecutionOptions(func.executionOptions, metaTx) + if (execOptionsStatus !== Status.Ok) return execOptionsStatus + return func.wildcarded ? Status.Ok : null // wildcarded means there's no condition set + } + } + + return Status.FunctionNotAllowed +} + +const checkExecutionOptions = (execOptions: ExecutionOptions, metaTx: MetaTransactionData): Status => { + const isSend = BigInt(metaTx.value || '0') > 0n + const isDelegateCall = metaTx.operation === OperationType.DelegateCall + + if (isSend && execOptions !== ExecutionOptions.Send && execOptions !== ExecutionOptions.Both) { + return Status.SendNotAllowed + } + if (isDelegateCall && execOptions !== ExecutionOptions.DelegateCall && execOptions !== ExecutionOptions.Both) { + return Status.DelegateCallNotAllowed + } + + return Status.Ok +} + +export const useExecuteThroughRole = ({ + modAddress, + roleKey, + metaTx, +}: { + modAddress?: `0x${string}` + roleKey?: `0x${string}` + metaTx?: MetaTransactionData +}) => { + const web3ReadOnly = useWeb3ReadOnly() + const wallet = useWallet() + const walletAddress = wallet?.address.toLowerCase() as undefined | `0x${string}` + + return useMemo( + () => + modAddress && roleKey && metaTx && walletAddress && web3ReadOnly + ? encodeExecuteThroughRole(modAddress, roleKey, metaTx, walletAddress, web3ReadOnly) + : undefined, + [modAddress, roleKey, metaTx, walletAddress, web3ReadOnly], + ) +} + +const encodeExecuteThroughRole = ( + modAddress: `0x${string}`, + roleKey: `0x${string}`, + metaTx: MetaTransactionData, + from: `0x${string}`, + provider: JsonRpcProvider, +): Transaction => { + const rolesModifier = getModuleInstance(KnownContracts.ROLES_V2, modAddress, provider) + const data = rolesModifier.interface.encodeFunctionData('execTransactionWithRole', [ + metaTx.to, + BigInt(metaTx.value), + metaTx.data, + metaTx.operation || 0, + roleKey, + true, + ]) + + return { + to: modAddress, + data, + value: '0', + from, + } +} + +const checkCondition = async ( + modAddress: `0x${string}`, + roleKey: `0x${string}`, + metaTx: MetaTransactionData, + from: `0x${string}`, + provider: JsonRpcProvider, +) => { + const rolesModifier = getModuleInstance(KnownContracts.ROLES_V2, modAddress, provider) + try { + await rolesModifier.execTransactionWithRole.estimateGas( + metaTx.to, + BigInt(metaTx.value), + metaTx.data, + metaTx.operation || 0, + roleKey, + false, + { from }, + ) + + return Status.Ok + } catch (e: any) { + const error = rolesModifier.interface.getError(e.data.slice(0, 10)) + if (error === null || error.name !== 'ConditionViolation') { + console.error('Unexpected error in condition check', error, e.data, e) + return null + } + + // status is a BigInt, convert it to enum + const { status } = rolesModifier.interface.decodeErrorResult(error, e.data) + return Number(status) as Status + } +} + +export const useGasLimit = ( + tx?: Transaction, +): { + gasLimit?: bigint + gasLimitError?: Error + gasLimitLoading: boolean +} => { + const web3ReadOnly = useWeb3ReadOnly() + + const [gasLimit, gasLimitError, gasLimitLoading] = useAsync(async () => { + if (!web3ReadOnly || !tx) return + + return web3ReadOnly.estimateGas(tx) + }, [web3ReadOnly, tx]) + + useEffect(() => { + if (gasLimitError) { + logError(Errors._612, gasLimitError.message) + } + }, [gasLimitError]) + + return { gasLimit, gasLimitError, gasLimitLoading } +} + +export const pollModuleTransactionId = async (props: { + transactionService: string + safeAddress: string + txHash: string +}): Promise => { + // exponential delay between attempts for around 4 min + return backOff(() => getModuleTransactionId(props), { + startingDelay: 750, + maxDelay: 20000, + numOfAttempts: 19, + retry: (e: any) => { + console.info('waiting for transaction-service to index the module transaction', e) + return true + }, + }) +} diff --git a/src/components/tx/SignOrExecuteForm/PermissionsCheck/index.tsx b/src/components/tx/SignOrExecuteForm/PermissionsCheck/index.tsx new file mode 100644 index 0000000000..edbac6d42e --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/PermissionsCheck/index.tsx @@ -0,0 +1,220 @@ +import { useContext, useState } from 'react' +import { Status } from 'zodiac-roles-deployments' +import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { decodeBytes32String } from 'ethers' + +import { Box, Button, CardActions, Chip, CircularProgress, Divider, Typography } from '@mui/material' + +import commonCss from '@/components/tx-flow/common/styles.module.css' +import CheckWallet from '@/components/common/CheckWallet' +import TxCard from '@/components/tx-flow/common/TxCard' +import { getTransactionTrackingType } from '@/services/analytics/tx-tracking' +import { TX_EVENTS } from '@/services/analytics/events/transactions' +import { trackEvent } from '@/services/analytics' +import useSafeInfo from '@/hooks/useSafeInfo' +import WalletRejectionError from '../WalletRejectionError' +import ErrorMessage from '../../ErrorMessage' +import useWallet from '@/hooks/wallets/useWallet' +import { type SubmitCallback } from '..' +import { getTxOptions } from '@/utils/transactions' +import { isWalletRejection } from '@/utils/wallets' +import { Errors, trackError } from '@/services/exceptions' +import { asError } from '@/services/exceptions/utils' +import { SuccessScreenFlow } from '@/components/tx-flow/flows' +import AdvancedParams, { useAdvancedParams } from '../../AdvancedParams' +import { useCurrentChain } from '@/hooks/useChains' +import { dispatchModuleTxExecution } from '@/services/tx/tx-sender' +import useOnboard from '@/hooks/wallets/useOnboard' +import { assertOnboard, assertWallet } from '@/utils/helpers' +import { TxModalContext } from '@/components/tx-flow' +import { pollModuleTransactionId, useExecuteThroughRole, useRoles, useGasLimit } from './hooks' +import { assertWalletChain } from '@/services/tx/tx-sender/sdk' + +const Role = ({ children }: { children: string }) => { + let humanReadableRoleKey = children + try { + humanReadableRoleKey = decodeBytes32String(children) + } catch (e) {} + + return +} + +const PermissionsCheck: React.FC<{ onSubmit?: SubmitCallback; safeTx: SafeTransaction; safeTxError?: Error }> = ({ + onSubmit, + safeTx, + safeTxError, +}) => { + const currentChain = useCurrentChain() + const onboard = useOnboard() + const wallet = useWallet() + const { safe } = useSafeInfo() + + const chainId = currentChain?.chainId || '1' + + const [isPending, setIsPending] = useState(false) + const [isRejectedByUser, setIsRejectedByUser] = useState(false) + const [submitError, setSubmitError] = useState() + + const { setTxFlow } = useContext(TxModalContext) + + const roles = useRoles(safeTx?.data) + const allowingRole = roles.find((role) => role.status === Status.Ok) + + // If a user has multiple roles, we should prioritize the one that allows the transaction's to address (and function selector) + const mostLikelyRole = + allowingRole || + roles.find((role) => role.status !== Status.TargetAddressNotAllowed && role.status !== Status.FunctionNotAllowed) || + roles.find((role) => role.status !== Status.TargetAddressNotAllowed) || + roles[0] + + // Wrap call routing it through the Roles mod with the allowing role + const txThroughRole = useExecuteThroughRole({ + modAddress: allowingRole?.modAddress, + roleKey: allowingRole?.roleKey, + metaTx: safeTx?.data, + }) + // Estimate gas limit + const { gasLimit, gasLimitError } = useGasLimit(txThroughRole) + const [advancedParams, setAdvancedParams] = useAdvancedParams(gasLimit) + + const handleExecute = async () => { + assertWallet(wallet) + assertOnboard(onboard) + + await assertWalletChain(onboard, chainId) + + setIsRejectedByUser(false) + setIsPending(true) + setSubmitError(undefined) + setIsRejectedByUser(false) + + if (!txThroughRole) { + throw new Error('Execution through role is not possible') + } + + const txOptions = getTxOptions(advancedParams, currentChain) + + let txHash: string + try { + txHash = await dispatchModuleTxExecution({ ...txThroughRole, ...txOptions }, wallet.provider, safe.address.value) + } catch (_err) { + const err = asError(_err) + if (isWalletRejection(err)) { + setIsRejectedByUser(true) + } else { + trackError(Errors._815, err) + setSubmitError(err) + } + setIsPending(false) + return + } + + // On success, forward to the success screen, initially without a txId + setTxFlow(, undefined, false) + + // Wait for module tx to be indexed + const transactionService = currentChain?.transactionService + if (!transactionService) { + throw new Error('Transaction service not found') + } + const moduleTxId = await pollModuleTransactionId({ + transactionService, + safeAddress: safe.address.value, + txHash, + }) + + const txId = `module_${safe.address.value}_${moduleTxId}` + + onSubmit?.(txId, true) + + // Track tx event + const txType = await getTransactionTrackingType(chainId, txId) + trackEvent({ ...TX_EVENTS.EXECUTE_THROUGH_ROLE, label: txType }) + + // Update the success screen so it shows a link to the transaction + setTxFlow(, undefined, false) + } + + // Only render the card if the connected wallet is a member of any role + if (roles.length === 0) { + return null + } + + return ( + + Execute without confirmations + + {allowingRole && ( + <> + + As a member of the {allowingRole.roleKey} you can execute this transaction immediately without + confirmations from other owners. + + + + )} + + {!allowingRole && ( + <> + + You are a member of the {mostLikelyRole.roleKey} role but it does not allow this transaction. + + + {mostLikelyRole.status && ( + + The permission check fails with the following status: +
+ {Status[mostLikelyRole.status]} +
+ )} + + )} + + {safeTxError && ( + + This transaction will most likely fail. To save gas costs, avoid confirming the transaction. + + )} + + {submitError && ( + + Error submitting the transaction. Please try again. + + )} + + {isRejectedByUser && ( + + + + )} + +
+ + + + + {(isOk) => ( + + )} + + +
+
+ ) +} + +export default PermissionsCheck diff --git a/src/components/tx/SignOrExecuteForm/index.tsx b/src/components/tx/SignOrExecuteForm/index.tsx index e85ad42121..c5b3517f88 100644 --- a/src/components/tx/SignOrExecuteForm/index.tsx +++ b/src/components/tx/SignOrExecuteForm/index.tsx @@ -26,6 +26,7 @@ import { getTransactionTrackingType } from '@/services/analytics/tx-tracking' import { TX_EVENTS } from '@/services/analytics/events/transactions' import { trackEvent } from '@/services/analytics' import useChainId from '@/hooks/useChainId' +import PermissionsCheck from './PermissionsCheck' export type SubmitCallback = (txId: string, isExecuted?: boolean) => void @@ -119,6 +120,12 @@ export const SignOrExecuteForm = ({ )} + {!isCounterfactualSafe && safeTx && isCreation && ( + + + + )} + new Date().getTimezoneOffset() * 60 * -1000 @@ -28,3 +29,31 @@ export const getTxHistory = (chainId: string, safeAddress: string, trusted = fal pageUrl, ) } + +/** + * Fetch the module transaction id from the transaction service providing the transaction hash + */ +export const getModuleTransactionId = async ({ + transactionService, + safeAddress, + txHash, +}: { + transactionService: string + safeAddress: string + txHash: string +}) => { + const url = `${trimTrailingSlash( + transactionService, + )}/api/v1/safes/${safeAddress}/module-transactions/?transaction_hash=${txHash}` + const { results } = await fetch(url).then((res) => { + if (res.ok && res.status === 200) { + return res.json() as Promise + } else { + throw new Error('Error fetching Safe module transactions') + } + }) + + if (results.length === 0) throw new Error('module transaction not found') + + return results[0].moduleTransactionId as string +} diff --git a/src/services/tx/tx-sender/dispatch.ts b/src/services/tx/tx-sender/dispatch.ts index 76d6510a1d..49261e41ad 100644 --- a/src/services/tx/tx-sender/dispatch.ts +++ b/src/services/tx/tx-sender/dispatch.ts @@ -1,5 +1,10 @@ import { relayTransaction, type SafeInfo, type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeTransaction, TransactionOptions, TransactionResult } from '@safe-global/safe-core-sdk-types' +import type { + SafeTransaction, + Transaction, + TransactionOptions, + TransactionResult, +} from '@safe-global/safe-core-sdk-types' import { didRevert } from '@/utils/ethers-utils' import type { MultiSendCallOnlyEthersContract } from '@safe-global/protocol-kit' import { type SpendingLimitTxParams } from '@/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx' @@ -315,6 +320,54 @@ export const dispatchBatchExecution = async ( return result!.hash } +/** + * Execute a module transaction + */ +export const dispatchModuleTxExecution = async ( + tx: Transaction, + provider: Eip1193Provider, + safeAddress: string, +): Promise => { + const id = JSON.stringify(tx) + + let result: TransactionResponse | undefined + try { + const browserProvider = createWeb3(provider) + const signer = await browserProvider.getSigner() + + txDispatch(TxEvent.EXECUTING, { groupKey: id }) + result = await signer.sendTransaction(tx) + } catch (error) { + txDispatch(TxEvent.FAILED, { groupKey: id, error: asError(error) }) + throw error + } + + txDispatch(TxEvent.PROCESSING_MODULE, { + groupKey: id, + txHash: result.hash, + }) + + result + ?.wait() + .then((receipt) => { + if (receipt === null) { + txDispatch(TxEvent.FAILED, { groupKey: id, error: new Error('No transaction receipt found') }) + } else if (didRevert(receipt)) { + txDispatch(TxEvent.REVERTED, { + groupKey: id, + error: new Error('Transaction reverted by EVM'), + }) + } else { + txDispatch(TxEvent.PROCESSED, { groupKey: id, safeAddress, txHash: result?.hash }) + } + }) + .catch((error) => { + txDispatch(TxEvent.FAILED, { groupKey: id, error: asError(error) }) + }) + + return result?.hash +} + export const dispatchSpendingLimitTxExecution = async ( txParams: SpendingLimitTxParams, txOptions: TransactionOptions, diff --git a/yarn.lock b/yarn.lock index 864e29157a..2e191e53b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20155,3 +20155,8 @@ yocto-queue@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== + +zodiac-roles-deployments@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/zodiac-roles-deployments/-/zodiac-roles-deployments-2.2.2.tgz#feb7e7544398e1572d2f7fa6ff2be033f2c736ce" + integrity sha512-6nG6/AuJh9SIrXR1NieRzSfn1+J6k9p2mb3qns3cRYhx3+i4wWIRh1JcE+jueHSYo+H7yKG/jfwRXMVN1L938A==