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

feat: Execute transaction through role #3768

Merged
merged 18 commits into from
Jun 5, 2024
Merged
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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -160,4 +161,4 @@
"minimumChangeThreshold": 0,
"showDetails": true
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 20 additions & 10 deletions src/components/tx-flow/flows/SuccessScreen/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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<string | undefined>(txHash)
const [error, setError] = useState<Error>()
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) =>
Expand All @@ -59,7 +68,8 @@ const SuccessScreen = ({ txId, safeTx }: { txId: string; safeTx?: SafeTransactio
switch (status) {
case PendingStatus.PROCESSING:
case PendingStatus.RELAYING:
StatusComponent = <ProcessingStatus txId={txId} pendingTx={pendingTx} />
// status can only have these values if txId & pendingTx are defined
StatusComponent = <ProcessingStatus txId={txId!} pendingTx={pendingTx!} />
break
case PendingStatus.INDEXING:
StatusComponent = <IndexingStatus />
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<PermissionsCheck safeTx={safeTx} />)

// 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(<PermissionsCheck safeTx={safeTx} />)
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(<PermissionsCheck safeTx={safeTx} onSubmit={onSubmit} />)

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:
* <div>
* <span>Hello</span><span> World</span>
* </div>
*
* 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
}
Loading
Loading