diff --git a/.changeset/fancy-moments-cross.md b/.changeset/fancy-moments-cross.md new file mode 100644 index 0000000000..13612462b2 --- /dev/null +++ b/.changeset/fancy-moments-cross.md @@ -0,0 +1,5 @@ +--- +'@aragon/app': patch +--- + +Implement unique enum ID for LockToVote dialog diff --git a/.changeset/hot-numbers-attack.md b/.changeset/hot-numbers-attack.md new file mode 100644 index 0000000000..41c16178d5 --- /dev/null +++ b/.changeset/hot-numbers-attack.md @@ -0,0 +1,5 @@ +--- +'@aragon/app': minor +--- + +Implement Tenderly simulations for proposal actions diff --git a/.changeset/nine-badgers-glow.md b/.changeset/nine-badgers-glow.md new file mode 100644 index 0000000000..866fc729aa --- /dev/null +++ b/.changeset/nine-badgers-glow.md @@ -0,0 +1,5 @@ +--- +'@aragon/app': patch +--- + +Strip all headers from RPC requests to prevent 413 errors diff --git a/.changeset/six-mugs-guess.md b/.changeset/six-mugs-guess.md new file mode 100644 index 0000000000..1a9f4c08e2 --- /dev/null +++ b/.changeset/six-mugs-guess.md @@ -0,0 +1,5 @@ +--- +'@aragon/app': patch +--- + +Fix calculation for stage 'Expiration period' on process details diff --git a/.changeset/two-sloths-look.md b/.changeset/two-sloths-look.md new file mode 100644 index 0000000000..20ee1b8796 --- /dev/null +++ b/.changeset/two-sloths-look.md @@ -0,0 +1,5 @@ +--- +'@aragon/app': patch +--- + +Fix isSupportReached utility for token-based plugins to handle support-threshold setting with decimals diff --git a/.changeset/weird-chains-break.md b/.changeset/weird-chains-break.md new file mode 100644 index 0000000000..bdb3eb6d54 --- /dev/null +++ b/.changeset/weird-chains-break.md @@ -0,0 +1,5 @@ +--- +'@aragon/app': patch +--- + +Add Lock To Vote plugin support for ZKsync and ZKsync Sepolia diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 8f4d0c3ca3..472f99bb89 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -748,6 +748,10 @@ } }, "createProposalPage": { + "createProposalPageClientSteps": { + "simulate": "Simulate actions", + "skipSimulation": "Skip simulation" + }, "finalStep": "Publish proposal", "steps": { "ACTIONS": { @@ -852,7 +856,9 @@ }, "main": { "actions": { - "header": "Actions" + "header": "Actions", + "lastSimulationError": "There was an error getting the last simulation data. Please try again later.", + "simulationError": "There was an error simulating the actions. Please try again later." }, "description": { "header": "Proposal", @@ -972,6 +978,15 @@ "title": "Create proposal" } }, + "simulateActionsDialog": { + "action": { + "cancel": "Cancel", + "error": "Continue anyway", + "success": "Continue" + }, + "error": "There was an error simulating the actions. Please try again later.", + "title": "Simulate actions" + }, "verifySmartContractDialog": { "action": { "add": "Add contract", diff --git a/src/modules/application/utils/proxyBackendUtils/proxyBackendUtils.test.ts b/src/modules/application/utils/proxyBackendUtils/proxyBackendUtils.test.ts index 8497c1e7a1..4d014336cd 100644 --- a/src/modules/application/utils/proxyBackendUtils/proxyBackendUtils.test.ts +++ b/src/modules/application/utils/proxyBackendUtils/proxyBackendUtils.test.ts @@ -21,12 +21,18 @@ describe('proxyBackend utils', () => { describe('request', () => { it('calls the rpc endpoint with the specified parameters, parses and returns the result', async () => { const parsedResponse = { result: 'test' }; - const fetchReturn = generateResponse({ json: jest.fn(() => Promise.resolve(parsedResponse)) }); + const headers = new Headers(); + const fetchReturn = generateResponse({ json: jest.fn(() => Promise.resolve(parsedResponse)), headers }); + const mockNextResponse = {} as NextResponse; fetchSpy.mockResolvedValue(fetchReturn); - await proxyBackendUtils.request(generateNextRequest()); + nextResponseJsonSpy.mockReturnValue(mockNextResponse); + + const result = await proxyBackendUtils.request(generateNextRequest()); + expect(fetchSpy).toHaveBeenCalled(); expect(fetchReturn.json).toHaveBeenCalled(); - expect(nextResponseJsonSpy).toHaveBeenCalledWith(parsedResponse); + expect(nextResponseJsonSpy).toHaveBeenCalledWith(parsedResponse, fetchReturn); + expect(result).toEqual(mockNextResponse); }); }); diff --git a/src/modules/application/utils/proxyBackendUtils/proxyBackendUtils.ts b/src/modules/application/utils/proxyBackendUtils/proxyBackendUtils.ts index 869fb0891a..f68adf0a33 100644 --- a/src/modules/application/utils/proxyBackendUtils/proxyBackendUtils.ts +++ b/src/modules/application/utils/proxyBackendUtils/proxyBackendUtils.ts @@ -5,11 +5,12 @@ export class ProxyBackendUtils { request = async (request: NextRequest) => { const url = this.buildBackendUrl(request); + const requestOptions = await this.buildRequestOptions(request); - const result = await fetch(url, request); + const result = await fetch(url, requestOptions); const parsedResult = (await result.json()) as unknown; - return NextResponse.json(parsedResult); + return NextResponse.json(parsedResult, result); }; private buildBackendUrl = (request: NextRequest): string => { @@ -18,6 +19,13 @@ export class ProxyBackendUtils { return url; }; + + private buildRequestOptions = async (request: NextRequest): Promise => { + const { method, headers } = request; + const body = method.toUpperCase() === 'POST' ? await request.text() : undefined; + + return { method, body, headers }; + }; } export const proxyBackendUtils = new ProxyBackendUtils(); diff --git a/src/modules/application/utils/proxyRpcUtils/proxyRpcUtils.test.ts b/src/modules/application/utils/proxyRpcUtils/proxyRpcUtils.test.ts index f1fb8aeecf..caf287cab8 100644 --- a/src/modules/application/utils/proxyRpcUtils/proxyRpcUtils.test.ts +++ b/src/modules/application/utils/proxyRpcUtils/proxyRpcUtils.test.ts @@ -115,13 +115,30 @@ describe('proxyRpc utils', () => { describe('buildRequestOptions', () => { it('returns the parameters for the fetch call', () => { const testClass = createTestClass(); - const request = generateRequest({ method: 'POST', body: {} as ReadableStream }); - expect(testClass['buildRequestOptions'](request)).toEqual({ - method: request.method, - body: request.body, - headers: request.headers, - duplex: 'half', + const request = generateRequest({ method: 'POST', body: {} as ReadableStream, credentials: 'include' }); + + const requestOptions = testClass['buildRequestOptions'](request); + + expect(requestOptions.method).toEqual(request.method); + expect(requestOptions.body).toEqual(request.body); + expect(requestOptions.duplex).toEqual('half'); + expect(requestOptions.credentials).toEqual('omit'); // always omit credentials on proxy requests + }); + + it('strips all headers from the request', () => { + const testClass = createTestClass(); + const headers = new Headers({ + 'test-header': 'test-value', + cookie: 'test-cookie=test-value', + Cookie: 'test-cookie=test-value', + }); + const request = generateRequest({ + headers, }); + + const requestOptions = testClass['buildRequestOptions'](request); + + expect(requestOptions.headers).toBeUndefined(); }); }); }); diff --git a/src/modules/application/utils/proxyRpcUtils/proxyRpcUtils.tsx b/src/modules/application/utils/proxyRpcUtils/proxyRpcUtils.tsx index 4269bf8f61..967f064771 100644 --- a/src/modules/application/utils/proxyRpcUtils/proxyRpcUtils.tsx +++ b/src/modules/application/utils/proxyRpcUtils/proxyRpcUtils.tsx @@ -53,9 +53,18 @@ export class ProxyRpcUtils { private chainIdToNetwork = (chainId: string): Network | undefined => Object.values(Network).find((network) => networkDefinitions[network as Network].id === Number(chainId)); - private buildRequestOptions = (request: Request): RequestInit => { - const { method, body, headers } = request; + // Return type extended to include Node-specific 'duplex' property used for streamed requests. + private buildRequestOptions = (request: Request): RequestInit & { duplex?: 'half' } => { + const { method, body } = request; - return { method, body, headers, duplex: 'half' } as RequestInit; + // Don't forward headers: avoid RPC 413 "Request Entity Too Large" errors caused by sending headers' data, specifically cookies. + // (Also, beneficial to prevent potential sensitive data leaks to 3rd party services.) + return { + method, + body, + // Ensure no implicit credential forwarding + credentials: 'omit', + duplex: 'half', + }; }; } diff --git a/src/modules/governance/api/actionSimulationService/actionSimulationService.api.ts b/src/modules/governance/api/actionSimulationService/actionSimulationService.api.ts new file mode 100644 index 0000000000..2de85150b9 --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/actionSimulationService.api.ts @@ -0,0 +1,56 @@ +import type { Network } from '@/shared/api/daoService'; +import type { IRequestUrlBodyParams, IRequestUrlParams } from '@/shared/api/httpService'; + +export interface ISimulateActionsUrlParams { + /** + * Network to simulate the actions on. + */ + network: Network; + /** + * Address of the plugin to simulate the actions for. Used as `from` address. + */ + pluginAddress: string; +} + +export interface ISimulateActionsItem { + /** + * Address to simulate the transaction to. + */ + to: string; + /** + * Transaction data to simulate. + */ + data: string; + /** + * Value to send with the transaction. + */ + value: string; +} + +export interface ISimulateActionsBody { + /** + * List of actions to simulate. + */ + actions: ISimulateActionsItem[]; +} + +export interface ISimulateActionsParams + extends IRequestUrlBodyParams {} + +export interface ISimulateProposalUrlParams { + /** + * ID of the proposal to simulate. + */ + proposalId: string; +} + +export interface ISimulateProposalParams extends IRequestUrlParams {} + +export interface IGetLastSimulationUrlParams { + /** + * ID of the proposal to get last simulation for. + */ + proposalId: string; +} + +export interface IGetLastSimulationParams extends IRequestUrlParams {} diff --git a/src/modules/governance/api/actionSimulationService/actionSimulationService.ts b/src/modules/governance/api/actionSimulationService/actionSimulationService.ts new file mode 100644 index 0000000000..4c0211739c --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/actionSimulationService.ts @@ -0,0 +1,35 @@ +import { AragonBackendService } from '@/shared/api/aragonBackendService'; +import type { + IGetLastSimulationParams, + ISimulateActionsParams, + ISimulateProposalParams, +} from './actionSimulationService.api'; +import type { ISimulationResult } from './domain'; + +class ActionSimulationService extends AragonBackendService { + private urls = { + simulateActions: '/v2/simulations/:network/plugin/:pluginAddress/simulate', + simulateProposal: '/v2/simulations/proposal/:proposalId', + getLastSimulation: '/v2/simulations/proposal/:proposalId', + }; + + simulateActions = async (params: ISimulateActionsParams): Promise => { + const result = await this.request(this.urls.simulateActions, params, { method: 'POST' }); + + return result; + }; + + simulateProposal = async (params: ISimulateProposalParams): Promise => { + const result = await this.request(this.urls.simulateProposal, params, { method: 'POST' }); + + return result; + }; + + getLastSimulation = async (params: IGetLastSimulationParams): Promise => { + const result = await this.request(this.urls.getLastSimulation, params); + + return result; + }; +} + +export const actionSimulationService = new ActionSimulationService(); diff --git a/src/modules/governance/api/actionSimulationService/actionSimulationServiceKeys.ts b/src/modules/governance/api/actionSimulationService/actionSimulationServiceKeys.ts new file mode 100644 index 0000000000..2b070f37e4 --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/actionSimulationServiceKeys.ts @@ -0,0 +1,9 @@ +import type { IGetLastSimulationParams } from './actionSimulationService.api'; + +export enum ActionSimulationServiceKey { + LAST_SIMULATION = 'LAST_SIMULATION', +} + +export const actionSimulationServiceKeys = { + lastSimulation: (params: IGetLastSimulationParams) => [ActionSimulationServiceKey.LAST_SIMULATION, params], +}; diff --git a/src/modules/governance/api/actionSimulationService/domain/index.ts b/src/modules/governance/api/actionSimulationService/domain/index.ts new file mode 100644 index 0000000000..44e27b007f --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/domain/index.ts @@ -0,0 +1 @@ +export type { ISimulationResult } from './simulationResult'; diff --git a/src/modules/governance/api/actionSimulationService/domain/simulationResult.ts b/src/modules/governance/api/actionSimulationService/domain/simulationResult.ts new file mode 100644 index 0000000000..ce1fc6dbfc --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/domain/simulationResult.ts @@ -0,0 +1,14 @@ +export interface ISimulationResult { + /** + * Timestamp when the simulation was run. + */ + runAt: number; + /** + * Status of the simulation. + */ + status: 'success' | 'failed'; + /** + * URL to view the simulation in Tenderly. + */ + url: string; +} diff --git a/src/modules/governance/api/actionSimulationService/index.ts b/src/modules/governance/api/actionSimulationService/index.ts new file mode 100644 index 0000000000..3f8ca1809b --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/index.ts @@ -0,0 +1,6 @@ +export { actionSimulationService } from './actionSimulationService'; +export type * from './actionSimulationService.api'; +export { ActionSimulationServiceKey, actionSimulationServiceKeys } from './actionSimulationServiceKeys'; +export type * from './domain'; +export * from './mutations'; +export * from './queries'; diff --git a/src/modules/governance/api/actionSimulationService/mutations/index.ts b/src/modules/governance/api/actionSimulationService/mutations/index.ts new file mode 100644 index 0000000000..0a0ec00c5e --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/mutations/index.ts @@ -0,0 +1,2 @@ +export * from './useSimulateActions'; +export * from './useSimulateProposal'; diff --git a/src/modules/governance/api/actionSimulationService/mutations/useSimulateActions/index.ts b/src/modules/governance/api/actionSimulationService/mutations/useSimulateActions/index.ts new file mode 100644 index 0000000000..332b18bb4e --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/mutations/useSimulateActions/index.ts @@ -0,0 +1 @@ +export { useSimulateActions } from './useSimulateActions'; diff --git a/src/modules/governance/api/actionSimulationService/mutations/useSimulateActions/useSimulateActions.test.ts b/src/modules/governance/api/actionSimulationService/mutations/useSimulateActions/useSimulateActions.test.ts new file mode 100644 index 0000000000..3965536fd3 --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/mutations/useSimulateActions/useSimulateActions.test.ts @@ -0,0 +1,27 @@ +import { generateSimulationResult } from '@/modules/governance/testUtils'; +import { Network } from '@/shared/api/daoService'; +import { ReactQueryWrapper } from '@/shared/testUtils'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { actionSimulationService } from '../../actionSimulationService'; +import { useSimulateActions } from './useSimulateActions'; + +describe('useSimulateActions mutation', () => { + const simulateActionsSpy = jest.spyOn(actionSimulationService, 'simulateActions'); + + afterEach(() => { + simulateActionsSpy.mockReset(); + }); + + it('simulates actions and returns the result', async () => { + const simulationResult = generateSimulationResult(); + const params = { + urlParams: { network: Network.ETHEREUM_MAINNET, pluginAddress: '0x123' }, + body: { actions: [{ to: '0x456', data: '0x000', value: '0' }] }, + }; + simulateActionsSpy.mockResolvedValue(simulationResult); + const { result } = renderHook(() => useSimulateActions(), { wrapper: ReactQueryWrapper }); + act(() => result.current.mutate(params)); + await waitFor(() => expect(result.current.data).toEqual(simulationResult)); + expect(simulateActionsSpy).toHaveBeenCalledWith(params); + }); +}); diff --git a/src/modules/governance/api/actionSimulationService/mutations/useSimulateActions/useSimulateActions.ts b/src/modules/governance/api/actionSimulationService/mutations/useSimulateActions/useSimulateActions.ts new file mode 100644 index 0000000000..6eb3aaa094 --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/mutations/useSimulateActions/useSimulateActions.ts @@ -0,0 +1,11 @@ +import { useMutation, type MutationOptions } from '@tanstack/react-query'; +import { actionSimulationService } from '../../actionSimulationService'; +import type { ISimulateActionsParams } from '../../actionSimulationService.api'; +import type { ISimulationResult } from '../../domain'; + +export const useSimulateActions = (options?: MutationOptions) => { + return useMutation({ + mutationFn: (params) => actionSimulationService.simulateActions(params), + ...options, + }); +}; diff --git a/src/modules/governance/api/actionSimulationService/mutations/useSimulateProposal/index.ts b/src/modules/governance/api/actionSimulationService/mutations/useSimulateProposal/index.ts new file mode 100644 index 0000000000..2dba000a6e --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/mutations/useSimulateProposal/index.ts @@ -0,0 +1 @@ +export { useSimulateProposal } from './useSimulateProposal'; diff --git a/src/modules/governance/api/actionSimulationService/mutations/useSimulateProposal/useSimulateProposal.ts b/src/modules/governance/api/actionSimulationService/mutations/useSimulateProposal/useSimulateProposal.ts new file mode 100644 index 0000000000..fec7a78be2 --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/mutations/useSimulateProposal/useSimulateProposal.ts @@ -0,0 +1,11 @@ +import { useMutation, type MutationOptions } from '@tanstack/react-query'; +import { actionSimulationService } from '../../actionSimulationService'; +import type { ISimulateProposalParams } from '../../actionSimulationService.api'; +import type { ISimulationResult } from '../../domain'; + +export const useSimulateProposal = (options?: MutationOptions) => { + return useMutation({ + mutationFn: (params) => actionSimulationService.simulateProposal(params), + ...options, + }); +}; diff --git a/src/modules/governance/api/actionSimulationService/queries/index.ts b/src/modules/governance/api/actionSimulationService/queries/index.ts new file mode 100644 index 0000000000..81b6e9d343 --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/queries/index.ts @@ -0,0 +1 @@ +export * from './useLastSimulation'; diff --git a/src/modules/governance/api/actionSimulationService/queries/useLastSimulation/index.ts b/src/modules/governance/api/actionSimulationService/queries/useLastSimulation/index.ts new file mode 100644 index 0000000000..a94560519b --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/queries/useLastSimulation/index.ts @@ -0,0 +1 @@ +export { lastSimulationOptions, useLastSimulation } from './useLastSimulation'; diff --git a/src/modules/governance/api/actionSimulationService/queries/useLastSimulation/useLastSimulation.test.ts b/src/modules/governance/api/actionSimulationService/queries/useLastSimulation/useLastSimulation.test.ts new file mode 100644 index 0000000000..b708b95f06 --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/queries/useLastSimulation/useLastSimulation.test.ts @@ -0,0 +1,22 @@ +import { generateSimulationResult } from '@/modules/governance/testUtils'; +import { ReactQueryWrapper } from '@/shared/testUtils'; +import { renderHook, waitFor } from '@testing-library/react'; +import { actionSimulationService } from '../../actionSimulationService'; +import { useLastSimulation } from './useLastSimulation'; + +describe('useLastSimulation query', () => { + const getLastSimulationSpy = jest.spyOn(actionSimulationService, 'getLastSimulation'); + + afterEach(() => { + getLastSimulationSpy.mockReset(); + }); + + it('fetches last simulation result for a proposal', async () => { + const simulationResult = generateSimulationResult(); + const params = { urlParams: { proposalId: 'proposal-123' } }; + getLastSimulationSpy.mockResolvedValue(simulationResult); + const { result } = renderHook(() => useLastSimulation(params), { wrapper: ReactQueryWrapper }); + await waitFor(() => expect(result.current.data).toEqual(simulationResult)); + expect(getLastSimulationSpy).toHaveBeenCalledWith(params); + }); +}); diff --git a/src/modules/governance/api/actionSimulationService/queries/useLastSimulation/useLastSimulation.ts b/src/modules/governance/api/actionSimulationService/queries/useLastSimulation/useLastSimulation.ts new file mode 100644 index 0000000000..73403b9221 --- /dev/null +++ b/src/modules/governance/api/actionSimulationService/queries/useLastSimulation/useLastSimulation.ts @@ -0,0 +1,19 @@ +import type { QueryOptions, SharedQueryOptions } from '@/shared/types'; +import { useQuery } from '@tanstack/react-query'; +import { actionSimulationService } from '../../actionSimulationService'; +import type { IGetLastSimulationParams } from '../../actionSimulationService.api'; +import { actionSimulationServiceKeys } from '../../actionSimulationServiceKeys'; +import type { ISimulationResult } from '../../domain'; + +export const lastSimulationOptions = ( + params: IGetLastSimulationParams, + options?: QueryOptions, +): SharedQueryOptions => ({ + queryKey: actionSimulationServiceKeys.lastSimulation(params), + queryFn: () => actionSimulationService.getLastSimulation(params), + ...options, +}); + +export const useLastSimulation = (params: IGetLastSimulationParams, options?: QueryOptions) => { + return useQuery(lastSimulationOptions(params, options)); +}; diff --git a/src/modules/governance/constants/governanceDialogId.ts b/src/modules/governance/constants/governanceDialogId.ts index ecad789dcb..69ac79fae6 100644 --- a/src/modules/governance/constants/governanceDialogId.ts +++ b/src/modules/governance/constants/governanceDialogId.ts @@ -6,4 +6,5 @@ export enum GovernanceDialogId { VERIFY_SMART_CONTRACT = 'VERIFY_SMART_CONTRACT', PERMISSION_CHECK = 'PERMISSION_CHECK', WALLET_CONNECT_ACTION = 'WALLET_CONNECT_ACTION', + SIMULATE_ACTIONS = 'SIMULATE_ACTIONS', } diff --git a/src/modules/governance/constants/governanceDialogsDefinitions.ts b/src/modules/governance/constants/governanceDialogsDefinitions.ts index 0c69d71d3b..af5e077863 100644 --- a/src/modules/governance/constants/governanceDialogsDefinitions.ts +++ b/src/modules/governance/constants/governanceDialogsDefinitions.ts @@ -3,6 +3,7 @@ import { ExecuteDialog } from '../dialogs/executeDialog'; import { PermissionCheckDialog } from '../dialogs/permissionCheckDialog'; import { PublishProposalDialog } from '../dialogs/publishProposalDialog'; import { SelectPluginDialog } from '../dialogs/selectPluginDialog'; +import { SimulateActionsDialog } from '../dialogs/simulateActionsDialog'; import { VerifySmartContractDialog } from '../dialogs/verifySmartContractDialog'; import { VoteDialog } from '../dialogs/voteDialog'; import { WalletConnectActionDialog } from '../dialogs/walletConnectActionDialog'; @@ -19,4 +20,5 @@ export const governanceDialogsDefinitions: Record + import('./simulateActionsDialog').then((mod) => mod.SimulateActionsDialog), +); +export type { ISimulateActionsDialogParams, ISimulateActionsDialogProps } from './simulateActionsDialog'; diff --git a/src/modules/governance/dialogs/simulateActionsDialog/simulateActionsDialog.tsx b/src/modules/governance/dialogs/simulateActionsDialog/simulateActionsDialog.tsx new file mode 100644 index 0000000000..856f85a331 --- /dev/null +++ b/src/modules/governance/dialogs/simulateActionsDialog/simulateActionsDialog.tsx @@ -0,0 +1,85 @@ +import type { Network } from '@/shared/api/daoService'; +import { useDialogContext, type IDialogComponentProps } from '@/shared/components/dialogProvider'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { ActionSimulation, Dialog, invariant } from '@aragon/gov-ui-kit'; +import { useEffect } from 'react'; +import { useSimulateActions } from '../../api/actionSimulationService'; +import type { IProposalCreateAction } from '../publishProposalDialog'; + +export interface ISimulateActionsDialogParams { + /** + * Network of the DAO. + */ + network: Network; + /** + * Address of the plugin on which the proposal is created. + */ + pluginAddress: string; + /** + * List of actions to simulate. + */ + actions: IProposalCreateAction[]; + /** + * ID of the form to trigger the submit for. + */ + formId?: string; +} + +export interface ISimulateActionsDialogProps extends IDialogComponentProps {} + +export const SimulateActionsDialog: React.FC = (props) => { + const { location } = props; + + invariant(location.params != null, 'SimulateActionsDialog: params must be set for the dialog to work correctly'); + const { actions, network, pluginAddress, formId } = location.params; + + const { t } = useTranslations(); + const { close } = useDialogContext(); + + const { mutate: simulateActions, isError, isPending, status, data } = useSimulateActions(); + + useEffect(() => { + if (status !== 'idle') { + return; + } + + const urlParams = { network, pluginAddress }; + const processedActions = actions.map(({ to, data, value }) => ({ to, data, value: value.toString() })); + simulateActions({ urlParams, body: { actions: processedActions } }); + }, [actions, network, pluginAddress, status, simulateActions]); + + const hasSimulationFailed = isError || data?.status === 'failed'; + const lastSimulation = data != null ? { ...data, timestamp: data.runAt } : undefined; + + const error = isError ? t('app.governance.simulateActionsDialog.error') : undefined; + const primaryLabel = t(`app.governance.simulateActionsDialog.action.${hasSimulationFailed ? 'error' : 'success'}`); + + return ( + <> + + + + + close(), + }} + secondaryAction={{ + label: t('app.governance.simulateActionsDialog.action.cancel'), + onClick: () => close(), + }} + /> + + ); +}; diff --git a/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx b/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx index 8205f51193..aedc63f947 100644 --- a/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx +++ b/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx @@ -15,7 +15,7 @@ import type { } from '../../dialogs/publishProposalDialog'; import { useProposalPermissionCheckGuard } from '../../hooks/useProposalPermissionCheckGuard'; import { CreateProposalPageClientSteps } from './createProposalPageClientSteps'; -import { createProposalWizardSteps } from './createProposalPageDefinitions'; +import { createProposalWizardId, createProposalWizardSteps } from './createProposalPageDefinitions'; export interface ICreateProposalPageClientProps { /** @@ -73,6 +73,7 @@ export const CreateProposalPageClient: React.FC initialSteps={processedSteps} onSubmit={handleFormSubmit} defaultValues={{ actions: [] }} + id={createProposalWizardId} > diff --git a/src/modules/governance/pages/createProposalPage/createProposalPageClientSteps.test.tsx b/src/modules/governance/pages/createProposalPage/createProposalPageClientSteps.test.tsx index 73952b02bb..46b02c0899 100644 --- a/src/modules/governance/pages/createProposalPage/createProposalPageClientSteps.test.tsx +++ b/src/modules/governance/pages/createProposalPage/createProposalPageClientSteps.test.tsx @@ -1,9 +1,11 @@ +import * as DialogProvider from '@/shared/components/dialogProvider'; import type { IWizardPageStepProps } from '@/shared/components/wizards/wizardPage'; import * as useDaoPlugins from '@/shared/hooks/useDaoPlugins'; -import { generateTabComponentPlugin } from '@/shared/testUtils'; +import { generateDialogContext, generateTabComponentPlugin } from '@/shared/testUtils'; import { pluginRegistryUtils } from '@/shared/utils/pluginRegistryUtils'; import { render, screen } from '@testing-library/react'; import * as ReactHookForm from 'react-hook-form'; +import * as CreateProposalProvider from '../../components/createProposalForm/createProposalFormProvider'; import { CreateProposalPageClientSteps, type ICreateProposalPageClientStepsProps, @@ -32,22 +34,31 @@ describe(' component', () => { const useWatchSpy: jest.SpyInstance = jest.spyOn(ReactHookForm, 'useWatch'); const useDaoPluginsSpy = jest.spyOn(useDaoPlugins, 'useDaoPlugins'); const getSlotComponentSpy = jest.spyOn(pluginRegistryUtils, 'getSlotComponent'); + const useDialogContextSpy = jest.spyOn(DialogProvider, 'useDialogContext'); + const useCreateProposalFormContextSpy = jest.spyOn(CreateProposalProvider, 'useCreateProposalFormContext'); beforeEach(() => { useWatchSpy.mockReturnValue(true); useDaoPluginsSpy.mockReturnValue([generateTabComponentPlugin()]); getSlotComponentSpy.mockReturnValue(undefined); + useDialogContextSpy.mockReturnValue(generateDialogContext()); + useCreateProposalFormContextSpy.mockReturnValue({ + prepareActions: {}, + addPrepareAction: jest.fn(), + }); }); afterEach(() => { useWatchSpy.mockReset(); useDaoPluginsSpy.mockReset(); getSlotComponentSpy.mockReset(); + useDialogContextSpy.mockReset(); + useCreateProposalFormContextSpy.mockReset(); }); const createTestComponent = (props?: Partial) => { const completeProps: ICreateProposalPageClientStepsProps = { - daoId: 'test', + daoId: 'ethereum-mainnet-0x123', pluginAddress: '0x123', steps: createProposalWizardSteps, ...props, diff --git a/src/modules/governance/pages/createProposalPage/createProposalPageClientSteps.tsx b/src/modules/governance/pages/createProposalPage/createProposalPageClientSteps.tsx index 425c91e96b..744a061c02 100644 --- a/src/modules/governance/pages/createProposalPage/createProposalPageClientSteps.tsx +++ b/src/modules/governance/pages/createProposalPage/createProposalPageClientSteps.tsx @@ -1,14 +1,21 @@ 'use client'; +import { useDialogContext } from '@/shared/components/dialogProvider'; import { useTranslations } from '@/shared/components/translationsProvider'; -import type { IWizardStepperStep } from '@/shared/components/wizards/wizard'; +import { type IWizardStepperStep } from '@/shared/components/wizards/wizard'; import { WizardPage } from '@/shared/components/wizards/wizardPage'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; +import { daoUtils } from '@/shared/utils/daoUtils'; import { pluginRegistryUtils } from '@/shared/utils/pluginRegistryUtils'; import { useWatch } from 'react-hook-form'; import { CreateProposalForm, type ICreateProposalFormData } from '../../components/createProposalForm'; +import { useCreateProposalFormContext } from '../../components/createProposalForm/createProposalFormProvider'; +import { GovernanceDialogId } from '../../constants/governanceDialogId'; import { GovernanceSlotId } from '../../constants/moduleSlots'; -import { CreateProposalWizardStep } from './createProposalPageDefinitions'; +import { publishProposalDialogUtils } from '../../dialogs/publishProposalDialog/publishProposalDialogUtils'; +import type { ISimulateActionsDialogParams } from '../../dialogs/simulateActionsDialog'; +import { createProposalWizardId, CreateProposalWizardStep } from './createProposalPageDefinitions'; export interface ICreateProposalPageClientStepsProps { /** @@ -29,7 +36,11 @@ export const CreateProposalPageClientSteps: React.FC({ name: 'addActions' }); + const actions = useWatch>({ name: 'actions' }); + const { prepareActions } = useCreateProposalFormContext(); const [metadataStep, actionsStep, settingsStep] = steps; @@ -38,6 +49,34 @@ export const CreateProposalPageClientSteps: React.FC { + const processedActions = await publishProposalDialogUtils.prepareActions({ actions, prepareActions }); + + const { network } = daoUtils.parseDaoId(daoId); + + const params: ISimulateActionsDialogParams = { + network, + pluginAddress, + actions: processedActions, + formId: createProposalWizardId, + }; + open(GovernanceDialogId.SIMULATE_ACTIONS, { params }); + }; + + const getActionStepDropdownItems = () => { + const labelBase = 'app.governance.createProposalPage.createProposalPageClientSteps'; + + const { network } = daoUtils.parseDaoId(daoId); + const { tenderlySupport } = networkDefinitions[network]; + + const dropdownItems = [ + { label: t(`${labelBase}.simulate`), onClick: handleSimulateActions }, + { label: t(`${labelBase}.skipSimulation`), formId: createProposalWizardId }, + ]; + + return actions.length > 0 && tenderlySupport ? dropdownItems : undefined; + }; + return ( <>