diff --git a/.agents/shared/metrics/hits.jsonl b/.agents/shared/metrics/hits.jsonl index 2d67d3ca29..7bdac7e1a0 100644 --- a/.agents/shared/metrics/hits.jsonl +++ b/.agents/shared/metrics/hits.jsonl @@ -157,4 +157,34 @@ {"ts":"2026-08-04T10:56:30.749Z","tool":"Edit","file":"apps/app/src/shared/api/daoService/domain/pluginSettings.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-04T07:16:05.722Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedActionDecoded.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-08-04T07:16:07.165Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-08-04T12:34:00.962Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} +{"ts":"2026-08-04T12:34:05.191Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-08-04T12:34:06.974Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":7,"adapter":"claude"} +{"ts":"2026-08-05T14:35:43.784Z","tool":"Write","file":"apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/domain/gasLimitEstimation.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":9,"adapter":"claude"} {"ts":"2026-08-10T03:51:44.869Z","tool":"Edit","file":"apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts","rule":"plugin-visibility","bytes":2379,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-08-10T08:06:36.294Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.test.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":20,"adapter":"claude"} +{"ts":"2026-08-10T08:06:52.432Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.test.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":13,"adapter":"claude"} +{"ts":"2026-08-10T08:07:04.518Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.test.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":10,"adapter":"claude"} +{"ts":"2026-08-10T08:07:16.141Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.test.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":20,"adapter":"claude"} +{"ts":"2026-08-10T08:07:37.317Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.test.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":3,"adapter":"claude"} +{"ts":"2026-08-10T08:10:50.408Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":5,"adapter":"claude"} +{"ts":"2026-08-10T08:10:56.137Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":7,"adapter":"claude"} +{"ts":"2026-08-10T08:11:01.264Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":9,"adapter":"claude"} +{"ts":"2026-08-10T08:11:07.906Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":8,"adapter":"claude"} +{"ts":"2026-08-10T08:11:15.340Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":12,"adapter":"claude"} +{"ts":"2026-08-10T08:11:25.613Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":18,"adapter":"claude"} +{"ts":"2026-08-10T08:11:37.484Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":13,"adapter":"claude"} +{"ts":"2026-08-10T08:11:49.652Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.test.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":17,"adapter":"claude"} +{"ts":"2026-08-10T08:11:56.129Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.test.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":17,"adapter":"claude"} +{"ts":"2026-08-10T08:12:03.831Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.test.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":11,"adapter":"claude"} +{"ts":"2026-08-10T08:12:26.490Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.test.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":10,"adapter":"claude"} +{"ts":"2026-08-10T08:12:36.525Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.test.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":20,"adapter":"claude"} +{"ts":"2026-08-10T08:14:14.567Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":5,"adapter":"claude"} +{"ts":"2026-08-12T10:14:42.115Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} +{"ts":"2026-08-12T10:14:45.890Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-08-12T10:14:55.980Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} +{"ts":"2026-08-12T10:16:49.526Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.test.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-08-13T09:36:23.360Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":3,"adapter":"claude"} +{"ts":"2026-08-13T11:08:25.801Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.test.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":3,"adapter":"claude"} +{"ts":"2026-08-13T11:09:00.109Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.test.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-08-13T11:10:01.732Z","tool":"Edit","file":"apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.test.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":3,"adapter":"claude"} diff --git a/.changeset/app-1032-cross-chain-action-details.md b/.changeset/app-1032-cross-chain-action-details.md new file mode 100644 index 0000000000..2013f84185 --- /dev/null +++ b/.changeset/app-1032-cross-chain-action-details.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": minor +--- + +Implement details view for decoded cross-chain execute actions diff --git a/.changeset/app-1055-enable-download-of-nested-actions.md b/.changeset/app-1055-enable-download-of-nested-actions.md new file mode 100644 index 0000000000..a447a7204f --- /dev/null +++ b/.changeset/app-1055-enable-download-of-nested-actions.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": none +--- + +Enable downloading nested actions from the proposal actions editor and cross-chain forward message details diff --git a/.changeset/olive-garlics-study.md b/.changeset/olive-garlics-study.md new file mode 100644 index 0000000000..13b15f0781 --- /dev/null +++ b/.changeset/olive-garlics-study.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": minor +--- + +Implement CrossChainControllerForwardMessageAction basic action diff --git a/.husky/pre-push b/.husky/pre-push index 0bbb2e7989..1361ce77b2 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -2,4 +2,4 @@ # Stage 2: Full parallel checks with caching echo "⚡ Running full checks with Turbo (parallel + cached)..." -pnpm turbo lint +pnpm turbo lint type-check test:changed diff --git a/apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.test.tsx b/apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.test.tsx new file mode 100644 index 0000000000..795f259889 --- /dev/null +++ b/apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.test.tsx @@ -0,0 +1,209 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { encodeAbiParameters, type Hex } from 'viem'; +import type { IProposalAction } from '@/modules/governance/api/governanceService'; +import type { IProposalActionData } from '@/modules/governance/components/createProposalForm'; +import type { IRawActionTuple } from '@/modules/governance/types'; +import { proposalActionsImportExportUtils } from '@/modules/governance/utils/proposalActionsImportExportUtils'; +import { forwardMessageActionsAbi } from '@/plugins/crossChainControllerPlugin/constants/crossChainControllerAbi'; +import type { ICrossChainControllerActionForwardMessage } from '../../types/crossChainControllerActionForwardMessage'; +import { CrossChainControllerActionType } from '../../types/enum/crossChainControllerActionType'; +import { + CrossChainControllerForwardMessageDetails, + type ICrossChainControllerForwardMessageDetailsProps, +} from './crossChainControllerForwardMessageDetails'; + +jest.mock('../crossChainControllerNestedActionsList', () => ({ + CrossChainControllerNestedActionsList: ({ + rawActions, + rawTuple, + chainId, + }: { + rawActions?: IProposalAction[]; + rawTuple?: IRawActionTuple[]; + chainId?: number; + }) => ( +
+ {`nested-count:${(rawActions ?? []).length.toString()} tuple-count:${(rawTuple ?? []).length.toString()} chain-id:${chainId?.toString() ?? ''}`} +
+ ), +})); + +describe(' component', () => { + const downloadActionsSpy = jest.spyOn( + proposalActionsImportExportUtils, + 'downloadActionsAsJSON', + ); + + beforeEach(() => { + downloadActionsSpy.mockImplementation(() => undefined); + }); + + afterEach(() => { + downloadActionsSpy.mockReset(); + }); + + const encodeMessage = (actions: IRawActionTuple[]): Hex => + encodeAbiParameters(forwardMessageActionsAbi, [ + actions.map(({ to, value, data }) => ({ + to: to as Hex, + value: BigInt(value), + data: data as Hex, + })), + ]); + + const buildAction = ( + params?: Partial<{ + message: string; + gasLimit: string; + destinationChainId: number; + actions: IProposalAction[]; + }>, + ): IProposalActionData => { + const { + message = encodeMessage([]), + gasLimit = '3000000', + destinationChainId = 42_161, + actions, + } = params ?? {}; + + return { + type: CrossChainControllerActionType.CROSS_CHAIN_CONTROLLER_FORWARD_MESSAGE, + from: '0x0', + to: '0x1', + data: '0x', + value: '0', + daoId: 'dao-id', + meta: undefined, + inputData: { + function: 'forwardMessage', + contract: 'CrossChainController', + destinationChainId, + actions, + parameters: [ + { + name: '_destinationChainId', + type: 'uint256', + value: destinationChainId.toString(), + }, + { name: '_gasLimit', type: 'uint256', value: gasLimit }, + { name: '_message', type: 'bytes', value: message }, + ], + }, + }; + }; + + const createTestComponent = ( + props?: Partial, + ) => { + const completeProps: ICrossChainControllerForwardMessageDetailsProps = { + action: buildAction(), + index: 0, + chainId: 1, + ...props, + }; + + return ( + + + + ); + }; + + const generateNestedAction = ( + overrides?: Partial, + ): IProposalAction => ({ + type: 'Unknown', + from: '0x0', + to: '0xa0Ab554dEa45be64F12E3B0085DDC59852eFF9fc', + data: '0xd09de08a', + value: '0', + inputData: null, + ...overrides, + }); + + it('renders the destination chain, the gas limit and the actions decoded from the message', () => { + const nestedAction = generateNestedAction(); + const action = buildAction({ + actions: [nestedAction], + message: encodeMessage([ + { + to: nestedAction.to, + value: nestedAction.value, + data: nestedAction.data, + }, + ]), + }); + + render(createTestComponent({ action })); + + expect(screen.getByText('Arbitrum')).toBeInTheDocument(); + expect(screen.getByText('3,000,000')).toBeInTheDocument(); + expect(screen.getByTestId('nested-actions-list')).toHaveTextContent( + 'nested-count:1 tuple-count:1 chain-id:42161', + ); + }); + + it('downloads the raw actions decoded from the message into a chain-scoped file', async () => { + const nestedAction = generateNestedAction(); + const rawTuple = { + to: nestedAction.to, + value: nestedAction.value, + data: nestedAction.data, + }; + const action = buildAction({ + actions: [nestedAction], + message: encodeMessage([rawTuple]), + }); + + render(createTestComponent({ action })); + await userEvent.click( + screen.getByRole('button', { + name: 'app.actions.crossChainController.crossChainControllerForwardMessageDetails.downloadActions', + }), + ); + + expect(downloadActionsSpy).toHaveBeenCalledWith( + [rawTuple], + 'cross-chain-42161-actions.json', + ); + }); + + it('renders no download button when the message holds no actions', () => { + render(createTestComponent({ action: buildAction() })); + + expect( + screen.queryByRole('button', { + name: 'app.actions.crossChainController.crossChainControllerForwardMessageDetails.downloadActions', + }), + ).not.toBeInTheDocument(); + }); + + it('renders the chain id when the destination chain is not supported by the app', () => { + const action = buildAction({ destinationChainId: 999 }); + + render(createTestComponent({ action })); + + expect( + screen.getByText( + 'app.actions.crossChainController.crossChainControllerForwardMessageDetails.chainUnknown (chainId=999)', + ), + ).toBeInTheDocument(); + }); + + it('renders a warning instead of the actions list when the message cannot be decoded', () => { + const action = buildAction({ message: '0x1234' }); + + render(createTestComponent({ action })); + + expect( + screen.queryByTestId('nested-actions-list'), + ).not.toBeInTheDocument(); + expect( + screen.getByText( + 'app.actions.crossChainController.crossChainControllerForwardMessageDetails.actionsDecodeError', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.tsx b/apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.tsx new file mode 100644 index 0000000000..81e15658c0 --- /dev/null +++ b/apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { + AlertInline, + Avatar, + Button, + DefinitionList, + formatterUtils, + InputContainer, + type IProposalAction, + type IProposalActionComponentProps, + NumberFormat, +} from '@aragon/gov-ui-kit'; +import { useMemo } from 'react'; +import { decodeAbiParameters, type Hex } from 'viem'; +import type { IProposalActionData } from '@/modules/governance/components/createProposalForm'; +import type { IRawActionTuple } from '@/modules/governance/types'; +import { proposalActionsImportExportUtils } from '@/modules/governance/utils/proposalActionsImportExportUtils'; +import { forwardMessageActionsAbi } from '@/plugins/crossChainControllerPlugin/constants/crossChainControllerAbi'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +import { networkUtils } from '@/shared/utils/networkUtils'; +import type { ICrossChainControllerActionForwardMessage } from '../../types/crossChainControllerActionForwardMessage'; +import { CrossChainControllerNestedActionsList } from '../crossChainControllerNestedActionsList'; + +export interface ICrossChainControllerForwardMessageDetailsProps + extends IProposalActionComponentProps< + IProposalActionData + > {} + +/** + * Decodes the `_message` payload into the raw actions tuple to check the decoded sub-actions against. + * @param message The `_message` parameter value of the `forwardMessage` call. + * @returns The raw actions tuple, or undefined when the payload does not hold an encoded `Action[]`. + */ +const decodeMessageActions = ( + message?: string, +): IRawActionTuple[] | undefined => { + if (message == null) { + return undefined; + } + + try { + const [actions] = decodeAbiParameters( + forwardMessageActionsAbi, + message as Hex, + ); + + return actions.map((action) => ({ + to: action.to, + value: action.value.toString(), + data: action.data, + })); + } catch { + return undefined; + } +}; + +export const CrossChainControllerForwardMessageDetails: React.FC< + ICrossChainControllerForwardMessageDetailsProps +> = (props) => { + const { action } = props; + + const { inputData } = + action as unknown as ICrossChainControllerActionForwardMessage; + + const { t } = useTranslations(); + + const { actions, parameters = [], destinationChainId: chainId } = inputData; + + const message = parameters.find( + (param) => param.name === '_message', + )?.value; + const gasLimit = parameters.find( + (param) => param.name === '_gasLimit', + )?.value; + + const messageActions = useMemo( + () => + decodeMessageActions( + typeof message === 'string' ? message : undefined, + ), + [message], + ); + + // The destination is resolved from the chain id instead of the network reported by the backend, so that chains not + // supported by the app are handled gracefully. + const destinationNetwork = networkUtils.getNetworkByChainId(chainId); + const destinationDefinition = + destinationNetwork != null + ? networkDefinitions[destinationNetwork] + : undefined; + + const formattedGasLimit = formatterUtils.formatNumber( + typeof gasLimit === 'string' ? gasLimit : null, + { format: NumberFormat.GENERIC_LONG }, + ); + + const handleDownloadActions = () => { + const fileName = + chainId != null + ? `cross-chain-${chainId.toString()}-actions.json` + : 'cross-chain-actions.json'; + + proposalActionsImportExportUtils.downloadActionsAsJSON( + messageActions ?? [], + fileName, + ); + }; + + return ( +
+ + + {destinationDefinition ? ( +
+ + {destinationDefinition.name} +
+ ) : ( + t( + 'app.actions.crossChainController.crossChainControllerForwardMessageDetails.chainUnknown', + { chainId }, + ) + )} +
+ {formattedGasLimit != null && ( + + {formattedGasLimit} + + )} +
+ + {messageActions == null ? ( + + ) : ( +
+ + {messageActions.length > 0 && ( + + )} +
+ )} +
+
+ ); +}; diff --git a/apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/index.ts b/apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/index.ts new file mode 100644 index 0000000000..8ebf76a940 --- /dev/null +++ b/apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/index.ts @@ -0,0 +1,2 @@ +export type { ICrossChainControllerForwardMessageDetailsProps } from './crossChainControllerForwardMessageDetails'; +export { CrossChainControllerForwardMessageDetails } from './crossChainControllerForwardMessageDetails'; diff --git a/apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.test.tsx b/apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.test.tsx new file mode 100644 index 0000000000..963fa09ec7 --- /dev/null +++ b/apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.test.tsx @@ -0,0 +1,110 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import type { IProposalAction } from '@/modules/governance/api/governanceService'; +import { proposalActionUtils } from '@/modules/governance/utils/proposalActionUtils'; +import { + CrossChainControllerNestedActionsList, + type ICrossChainControllerNestedActionsListProps, +} from './crossChainControllerNestedActionsList'; + +describe(' component', () => { + const normalizeDefaultActionSpy = jest.spyOn( + proposalActionUtils, + 'normalizeDefaultAction', + ); + + afterEach(() => { + normalizeDefaultActionSpy.mockClear(); + }); + + const createTestComponent = ( + props?: Partial, + ) => { + const completeProps: ICrossChainControllerNestedActionsListProps = { + rawTuple: [], + rawActions: [], + chainId: 42_161, + ...props, + }; + + return ( + + + + ); + }; + + const generateAction = ( + overrides?: Partial, + ): IProposalAction => ({ + type: 'Unknown', + from: '0x0', + to: '0xa0Ab554dEa45be64F12E3B0085DDC59852eFF9fc', + data: '0xd09de08a', + value: '0', + inputData: null, + ...overrides, + }); + + it('renders one item per decoded sub-action without resolving a custom action view', () => { + const rawActions = [ + generateAction({ to: '0xa' }), + generateAction({ to: '0xb' }), + ]; + + render( + createTestComponent({ + rawTuple: [ + { to: '0xa', value: '0', data: '0x' }, + { to: '0xb', value: '0', data: '0x' }, + ], + rawActions, + }), + ); + + expect(screen.getByText('0xa')).toBeInTheDocument(); + expect(screen.getByText('0xb')).toBeInTheDocument(); + }); + + it('falls back to raw-calldata stubs when the decoded length disagrees with the tuple', () => { + render( + createTestComponent({ + rawTuple: [ + { to: '0xa', value: '0', data: '0x' }, + { to: '0xb', value: '0', data: '0x' }, + ], + rawActions: [generateAction()], + }), + ); + + expect(screen.getByText('0xa')).toBeInTheDocument(); + expect(screen.getByText('0xb')).toBeInTheDocument(); + }); + + it('runs each rendered action through the default normalization function', () => { + const rawActions = [generateAction()]; + + render( + createTestComponent({ + rawTuple: [ + { + to: '0xa0Ab554dEa45be64F12E3B0085DDC59852eFF9fc', + value: '0', + data: '0xd09de08a', + }, + ], + rawActions, + }), + ); + + expect(normalizeDefaultActionSpy).toHaveBeenCalledWith(rawActions[0]); + }); + + it('renders nothing when both rawActions and rawTuple are empty', () => { + const { container } = render( + createTestComponent({ rawTuple: [], rawActions: undefined }), + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.tsx b/apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.tsx new file mode 100644 index 0000000000..5bf4a4fac1 --- /dev/null +++ b/apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { ProposalActions } from '@aragon/gov-ui-kit'; +import type { IProposalAction } from '@/modules/governance/api/governanceService'; +import type { IRawActionTuple } from '@/modules/governance/types'; +import { proposalActionUtils } from '@/modules/governance/utils/proposalActionUtils'; + +export interface ICrossChainControllerNestedActionsListProps { + /** + * Raw actions tuple decoded from the `_message` payload. Used to detect a mismatch with the decoded sub-actions. + */ + rawTuple: IRawActionTuple[]; + /** + * Decoded sub-actions emitted by the backend. When they do not describe the same calls as `rawTuple`, raw-calldata + * stubs are rendered instead. + */ + rawActions: IProposalAction[] | undefined; + /** + * Chain ID of the destination chain the actions execute on. + */ + chainId?: number; +} + +/** + * Renders the actions forwarded to another chain by a cross-chain controller message. Unlike `NestedActionsList`, + * actions are rendered with `ProposalActions.Item` directly and only go through the DAO-agnostic default + * normalization, without a plugin-specific `CustomComponent`: the DAO's own network and installed plugins belong to + * its home chain, not the destination chain the actions execute on, so resolving a plugin view for them would read + * the wrong chain's state. + */ +export const CrossChainControllerNestedActionsList: React.FC< + ICrossChainControllerNestedActionsListProps +> = (props) => { + const { rawTuple, rawActions, chainId } = props; + + const actions = proposalActionUtils + .resolveNestedActions(rawActions, rawTuple) + .map((action) => proposalActionUtils.normalizeDefaultAction(action)); + + if (actions.length === 0) { + return null; + } + + return ( + + + {actions.map((action, index) => ( + + ))} + + + ); +}; diff --git a/apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/index.ts b/apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/index.ts new file mode 100644 index 0000000000..3f1568008a --- /dev/null +++ b/apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/index.ts @@ -0,0 +1,2 @@ +export type { ICrossChainControllerNestedActionsListProps } from './crossChainControllerNestedActionsList'; +export { CrossChainControllerNestedActionsList } from './crossChainControllerNestedActionsList'; diff --git a/apps/app/src/actions/crossChainController/index.ts b/apps/app/src/actions/crossChainController/index.ts new file mode 100644 index 0000000000..1784f448b2 --- /dev/null +++ b/apps/app/src/actions/crossChainController/index.ts @@ -0,0 +1,11 @@ +import { actionViewRegistry } from '@/shared/utils/actionViewRegistry'; +import { CrossChainControllerForwardMessageDetails } from './components/crossChainControllerForwardMessageDetails'; +import { CrossChainControllerActionType } from './types/enum/crossChainControllerActionType'; + +export const initCrossChainControllerActionViews = () => { + actionViewRegistry.register({ + actionType: + CrossChainControllerActionType.CROSS_CHAIN_CONTROLLER_FORWARD_MESSAGE, + componentDetails: CrossChainControllerForwardMessageDetails, + }); +}; diff --git a/apps/app/src/actions/crossChainController/types/crossChainControllerActionForwardMessage.ts b/apps/app/src/actions/crossChainController/types/crossChainControllerActionForwardMessage.ts new file mode 100644 index 0000000000..90d740ec80 --- /dev/null +++ b/apps/app/src/actions/crossChainController/types/crossChainControllerActionForwardMessage.ts @@ -0,0 +1,32 @@ +import type { IProposalAction } from '@aragon/gov-ui-kit'; +import type { CrossChainControllerActionType } from './enum/crossChainControllerActionType'; + +/** + * Decoded `inputData` for a `forwardMessage` call on the cross-chain controller. Extends the base proposal-action input + * data with the destination chain and the sub-actions the backend decoded from the `_message` payload. + */ +export interface ICrossChainControllerActionForwardMessageInputData + extends NonNullable { + /** + * Sub-actions carried by the `_message` payload, executed as a batch on the destination chain. Populated only when + * the backend successfully decoded the encoded `Action[]`. + */ + actions?: IProposalAction[]; + /** + * Standard chain id of the chain the message is forwarded to, resolved by the backend from the + * `_destinationChainId` parameter. + */ + destinationChainId: number; +} + +export interface ICrossChainControllerActionForwardMessage + extends Omit { + /** + * Discriminator for the cross-chain controller forwardMessage action. + */ + type: CrossChainControllerActionType.CROSS_CHAIN_CONTROLLER_FORWARD_MESSAGE; + /** + * Decoded input data. + */ + inputData: ICrossChainControllerActionForwardMessageInputData; +} diff --git a/apps/app/src/actions/crossChainController/types/enum/crossChainControllerActionType.ts b/apps/app/src/actions/crossChainController/types/enum/crossChainControllerActionType.ts new file mode 100644 index 0000000000..54e049621e --- /dev/null +++ b/apps/app/src/actions/crossChainController/types/enum/crossChainControllerActionType.ts @@ -0,0 +1,3 @@ +export enum CrossChainControllerActionType { + CROSS_CHAIN_CONTROLLER_FORWARD_MESSAGE = 'CrossChainExecute', +} diff --git a/apps/app/src/actions/index.ts b/apps/app/src/actions/index.ts index c9769e71ee..2e89f77763 100644 --- a/apps/app/src/actions/index.ts +++ b/apps/app/src/actions/index.ts @@ -1,6 +1,7 @@ import { initCapitalDistributorActionViews } from './capitalDistributor'; import { capitalDistributorDialogsDefinitions } from './capitalDistributor/constants/capitalDistributorDialogsDefinitions'; import { initCoreActionViews } from './core'; +import { initCrossChainControllerActionViews } from './crossChainController'; import { initGaugeRegistrarActionViews } from './gaugeRegistrar'; import { gaugeRegistrarDialogsDefinitions } from './gaugeRegistrar/constants/gaugeRegistrarDialogsDefinitions'; import { initGaugeVoterActionViews } from './gaugeVoter'; @@ -8,6 +9,7 @@ import { gaugeVoterDialogsDefinitions } from './gaugeVoter/constants/gaugeVoterD export const initActionViewRegistry = () => { initCoreActionViews(); + initCrossChainControllerActionViews(); initGaugeRegistrarActionViews(); initGaugeVoterActionViews(); initCapitalDistributorActionViews(); diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index 4c6143d9fa..2ded9dc6b1 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -21,6 +21,17 @@ "actionsHelpText": "The actions that will be executed." } }, + "crossChainController": { + "crossChainControllerForwardMessageDetails": { + "chainTerm": "Destination chain", + "chainUnknown": "Chain {{chainId}}", + "gasLimitTerm": "Gas limit", + "actionsLabel": "Actions", + "actionsHelpText": "The actions that will be executed as a single batch on the destination chain once the message is delivered.", + "actionsDecodeError": "The forwarded message could not be decoded into actions. Check the raw calldata of this action before approving it.", + "downloadActions": "Download actions" + } + }, "gaugeRegistrar": { "composer": { "contractName": "Gauge Registrar", @@ -2236,6 +2247,7 @@ "decodeError": "We couldn't decode some of the actions. Please review them before saving.", "decoding": "Decoding actions", "description": "These actions are executed as a batch by the action you are composing.", + "loadingAllowedActions": "Loading the allowed actions", "prepareError": "We couldn't prepare the actions. Please try again.", "save": "Save actions", "title": "Add actions" @@ -2518,6 +2530,43 @@ "crossChainController": { "crossChainControllerActions": { "CrossChainControllerForwardMessage": "Forward message" + }, + "crossChainControllerForwardMessageAction": { + "actions": { + "add": "Add actions", + "edit": "Edit actions", + "emptyDescription": "Compose the actions the destination chain executes when the message is delivered.", + "emptyHeading": "No actions added", + "helpText": "These actions are executed as a single batch on the destination chain. If one of them fails, none of them are applied.", + "label": "Actions", + "selected": "{{count}} action selected", + "selectedPlural": "{{count}} actions selected" + }, + "chain": { + "empty": "This cross-chain controller has no other chain configured, so there is no destination to forward a message to.", + "helpText": "The chain the message is forwarded to. Only chains configured on this cross-chain controller can be selected.", + "label": "Destination chain", + "unknown": "Chain {{chainId}}" + }, + "fee": { + "defaultToken": "fee tokens", + "description": "The cross-chain controller ({{address}}) pays the fee to send the message. Make sure it holds enough {{token}}, otherwise the proposal execution will fail.", + "title": "Cross-chain fees are paid by the controller" + }, + "gas": { + "calculate": "Calculate", + "error": "The gas limit could not be calculated. Try again, or enter a value manually if the problem persists.", + "exceedsMax": "The actions need more gas than a single cross-chain message allows ({{maxGasLimit}}). Split them across several forward actions.", + "helpText": "The gas the destination chain may spend running the actions. Calculate it by simulating the actions, or enter it manually. It is fixed when the proposal is created and cannot be changed later, so it is cleared whenever the destination or the actions change.", + "label": "Destination gas limit", + "marginReduced": "Simulated successfully. The delivery needs {{requiredGas}} gas, but the lane does not allow the usual safety margin on top, so the limit was set to the maximum the lane accepts.", + "notWholeNumber": "Gas is counted in whole units, so the gas limit cannot have decimals.", + "placeholder": "Calculate or enter a gas limit", + "reverted": "The actions failed when simulated on the destination chain, so no gas limit could be measured: {{reason}}", + "simulated": "Simulated successfully. The delivery needs {{requiredGas}} gas, and a {{bufferPercent}}% safety margin was added on top.", + "unknownReason": "no reason given", + "viewSimulation": "View simulation" + } } }, "gaugeVoter": { diff --git a/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts b/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts index b01ccf31c0..20eac2d320 100644 --- a/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts +++ b/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts @@ -26,4 +26,9 @@ export interface IAllowedAction { * Whether the action is allowed or not. Should always be `true` for allowed actions. */ isAllowed: true; + /** + * Chain ID for which given allowed action is relevant. In general, value + * should be back-filled, but if not set, consider it as a chain id of the DAO. + */ + chainId?: number; } diff --git a/apps/app/src/modules/governance/api/executeSelectorsService/queries/index.ts b/apps/app/src/modules/governance/api/executeSelectorsService/queries/index.ts index 9c880945b1..3ce5862a79 100644 --- a/apps/app/src/modules/governance/api/executeSelectorsService/queries/index.ts +++ b/apps/app/src/modules/governance/api/executeSelectorsService/queries/index.ts @@ -1 +1,2 @@ +export * from './useAllAllowedActions'; export * from './useAllowedActions'; diff --git a/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/index.ts b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/index.ts new file mode 100644 index 0000000000..74ca9a3ff3 --- /dev/null +++ b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/index.ts @@ -0,0 +1,2 @@ +export type { IUseAllAllowedActionsParams } from './useAllAllowedActions'; +export { useAllAllowedActions } from './useAllAllowedActions'; diff --git a/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.test.ts b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.test.ts new file mode 100644 index 0000000000..d2f8907468 --- /dev/null +++ b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.test.ts @@ -0,0 +1,106 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { generateAllowedAction } from '@/modules/governance/testUtils'; +import { Network } from '@/shared/api/daoService'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +import { + generatePaginatedResponse, + ReactQueryWrapper, +} from '@/shared/testUtils'; +import { executeSelectorsService } from '../../executeSelectorsService'; +import { useAllAllowedActions } from './useAllAllowedActions'; + +describe('useAllAllowedActions query', () => { + const getAllowedActionsSpy = jest.spyOn( + executeSelectorsService, + 'getAllowedActions', + ); + + // The DAO is on Ethereum for every test, the requested chain is what varies. + const daoNetwork = Network.ETHEREUM_MAINNET; + const daoChainId = networkDefinitions[daoNetwork].id; + const otherChainId = networkDefinitions[Network.BASE_MAINNET].id; + + const daoChainAction = generateAllowedAction({ + target: '0xdaochain', + chainId: daoChainId, + }); + const otherChainAction = generateAllowedAction({ + target: '0xotherchain', + chainId: otherChainId, + }); + const noChainAction = generateAllowedAction({ target: '0xnochain' }); + + beforeEach(() => { + getAllowedActionsSpy.mockResolvedValue( + generatePaginatedResponse({ + data: [daoChainAction, otherChainAction, noChainAction], + }), + ); + }); + + afterEach(() => { + getAllowedActionsSpy.mockReset(); + }); + + const renderAllowedActionsHook = (chainId?: number) => + renderHook( + () => + useAllAllowedActions({ + urlParams: { network: daoNetwork, pluginAddress: '0x123' }, + chainId, + }), + { wrapper: ReactQueryWrapper }, + ); + + it('returns the actions of the specified chain and the actions without a chain ID when the specified chain is the DAO chain', async () => { + const { result } = renderAllowedActionsHook(daoChainId); + + await waitFor(() => + expect(result.current.data).toEqual([ + daoChainAction, + noChainAction, + ]), + ); + }); + + it('filters out the actions without a chain ID when the specified chain is not the DAO chain', async () => { + const { result } = renderAllowedActionsHook(otherChainId); + + await waitFor(() => + expect(result.current.data).toEqual([otherChainAction]), + ); + }); + + it('returns the actions of all chains when no chain ID is specified', async () => { + const { result } = renderAllowedActionsHook(); + + await waitFor(() => + expect(result.current.data).toEqual([ + daoChainAction, + otherChainAction, + noChainAction, + ]), + ); + }); + + // An empty list means "no action is authorized" to the consumers, so a disabled query must not + // resolve to one. The network is unset because that is what disables the query in the first place. + it('keeps the data undefined when the query is disabled', () => { + const { result } = renderHook( + () => + useAllAllowedActions( + { + urlParams: { + network: undefined as unknown as Network, + pluginAddress: '', + }, + }, + { enabled: false }, + ), + { wrapper: ReactQueryWrapper }, + ); + + expect(result.current.data).toBeUndefined(); + expect(getAllowedActionsSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts new file mode 100644 index 0000000000..d6d5f4ed33 --- /dev/null +++ b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts @@ -0,0 +1,106 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import type { IPaginatedResponse } from '@/shared/api/aragonBackendService'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +import type { InfiniteQueryOptions } from '@/shared/types'; +import type { IAllowedAction } from '../../domain'; +import type { + IGetAllowedActionsParams, + IGetAllowedActionsQueryParams, +} from '../../executeSelectorsService.api'; +import { useAllowedActions } from '../useAllowedActions'; + +/** + * Parameters of the useAllAllowedActions hook. + */ +export type IUseAllAllowedActionsParams = Omit< + IGetAllowedActionsParams, + 'queryParams' +> & { + /** + * Query parameters of the request, the page size is set by the hook. + */ + queryParams?: IGetAllowedActionsQueryParams; + /** + * Chain ID to return the allowed actions for, the actions of all chains are returned when not + * set. Actions without a chain ID are only relevant for the chain of the DAO, therefore they are + * only returned when this matches the DAO chain. + */ + chainId?: number; +}; + +/** + * Hook that fetches all allowed actions of a plugin by automatically loading all pages. Only returns + * the actions relevant for the specified chain when a chain ID is set. The data stays undefined until + * the full set is known, also when the query is disabled, so that an unknown allowlist can be told + * apart from an empty one. + */ +export const useAllAllowedActions = ( + params: IUseAllAllowedActionsParams, + options?: InfiniteQueryOptions< + IPaginatedResponse, + IGetAllowedActionsParams + >, +) => { + // Keep the chain ID out of the request params to avoid fetching the same actions once per chain. + const { chainId, ...requestParams } = params; + + const { + data, + isLoading, + error, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + refetch, + } = useAllowedActions( + { + ...requestParams, + queryParams: { ...requestParams.queryParams, pageSize: 50 }, + }, + options, + ); + + // While auto-paginating, `isLoading` only tracks the first page and `data` + // defaults to an empty array, so neither can express "the full action set + // is not ready yet". + const isFetchingAll = isLoading || hasNextPage || isFetchingNextPage; + + useEffect(() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [hasNextPage, fetchNextPage, isFetchingNextPage]); + + // Consumers disable the query while the network they fetch for is unknown, so the network is not + // guaranteed to resolve to a chain. + const daoChainId = networkDefinitions[params.urlParams.network]?.id; + + const allAllowedActions = useMemo(() => { + // A disabled query is neither loading nor holding data, so the loading flag alone cannot + // express that the actions are unknown. + if (data == null || isFetchingAll || error) { + return undefined; + } + + const actions = data.pages.flatMap((page) => page.data); + + if (chainId == null) { + return actions; + } + + // The chain ID of an action is not guaranteed to be back-filled, an action without it is + // considered to be on the DAO chain. + return actions.filter( + (action) => (action.chainId ?? daoChainId) === chainId, + ); + }, [data, isFetchingAll, error, chainId, daoChainId]); + + return { + data: allAllowedActions, + isLoading: isFetchingAll, + error, + refetch, + }; +}; diff --git a/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllowedActions/useAllowedActions.ts b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllowedActions/useAllowedActions.ts index 902a0d8c1e..5ce517e043 100644 --- a/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllowedActions/useAllowedActions.ts +++ b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllowedActions/useAllowedActions.ts @@ -1,9 +1,9 @@ import { useInfiniteQuery } from '@tanstack/react-query'; +import type { IPaginatedResponse } from '@/shared/api/aragonBackendService'; import type { InfiniteQueryOptions, SharedInfiniteQueryOptions, } from '@/shared/types'; -import type { IPaginatedResponse } from '../../../../../../shared/api/aragonBackendService'; import type { IAllowedAction } from '../../domain'; import { executeSelectorsService } from '../../executeSelectorsService'; import type { IGetAllowedActionsParams } from '../../executeSelectorsService.api'; @@ -21,7 +21,8 @@ export const allowedActionsOptions = ( > => ({ queryKey: executeSelectorsServiceKeys.allowedActions(params), initialPageParam: params, - queryFn: () => executeSelectorsService.getAllowedActions(params), + queryFn: ({ pageParam }) => + executeSelectorsService.getAllowedActions(pageParam), getNextPageParam: executeSelectorsService.getNextPageParams, ...options, }); diff --git a/apps/app/src/modules/governance/components/actionComposer/actionComposer/actionComposer.tsx b/apps/app/src/modules/governance/components/actionComposer/actionComposer/actionComposer.tsx index ad6861d0e2..4b26076ab3 100644 --- a/apps/app/src/modules/governance/components/actionComposer/actionComposer/actionComposer.tsx +++ b/apps/app/src/modules/governance/components/actionComposer/actionComposer/actionComposer.tsx @@ -56,7 +56,9 @@ export interface IActionComposerProps */ hideWalletConnect?: boolean; /** - * Allowed actions to show instead of default actions. + * Allowed actions to show instead of default actions. An empty list restricts the composer to no + * action at all, leave it undefined to offer every action. Only read on mount, so it must be + * resolved before the composer is rendered. */ allowedActions?: IAllowedAction[]; /** @@ -129,6 +131,8 @@ export const ActionComposer: React.FC = (props) => { const fileUploadInputRef = useRef(null); const [displayActionComposer, setDisplayActionComposer] = useState(false); + // An empty list means nothing is authorized, only an undefined list means unrestricted, so the + // callers must leave it undefined when no plugin restricts the actions. const [onlyShowAuthorizedActions, setOnlyShowAuthorizedActions] = useState( allowedActions != null, ); diff --git a/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.test.tsx b/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.test.tsx index 57884fb1d6..1276762e24 100644 --- a/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.test.tsx +++ b/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.test.tsx @@ -1,5 +1,6 @@ import { GukModulesProvider } from '@aragon/gov-ui-kit'; import { render, screen } from '@testing-library/react'; +import * as executeSelectorsService from '@/modules/governance/api/executeSelectorsService'; import * as daoService from '@/shared/api/daoService'; import * as DialogProvider from '@/shared/components/dialogProvider'; import { @@ -10,7 +11,7 @@ import { generateReactQueryResultSuccess, } from '@/shared/testUtils'; import { daoUtils } from '@/shared/utils/daoUtils'; -import * as CreateProposalProvider from '../createProposalFormProvider'; +import * as useDownloadProposalActionsHook from '../../../hooks/useDownloadProposalActions'; import { CreateProposalFormActions, type ICreateProposalFormActionsProps, @@ -22,10 +23,14 @@ describe(' component', () => { daoService, 'useAllDaoPermissions', ); + const useAllAllowedActionsSpy = jest.spyOn( + executeSelectorsService, + 'useAllAllowedActions', + ); const useDialogContextSpy = jest.spyOn(DialogProvider, 'useDialogContext'); - const useCreateProposalFormContextSpy = jest.spyOn( - CreateProposalProvider, - 'useCreateProposalFormContext', + const useDownloadProposalActionsSpy = jest.spyOn( + useDownloadProposalActionsHook, + 'useDownloadProposalActions', ); const getDaoPluginsSpy = jest.spyOn(daoUtils, 'getDaoPlugins'); @@ -38,10 +43,18 @@ describe(' component', () => { data: [], }) as unknown as ReturnType, ); + useAllAllowedActionsSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: [], + }) as unknown as ReturnType< + typeof executeSelectorsService.useAllAllowedActions + >, + ); useDialogContextSpy.mockReturnValue(generateDialogContext()); - useCreateProposalFormContextSpy.mockReturnValue({ - prepareActions: {}, - addPrepareAction: jest.fn(), + useDownloadProposalActionsSpy.mockReturnValue({ + isPinning: false, + hasPinErrors: false, + handleDownloadActions: jest.fn(), }); getDaoPluginsSpy.mockReturnValue([generateDaoPlugin()]); }); @@ -49,8 +62,9 @@ describe(' component', () => { afterEach(() => { useDaoSpy.mockReset(); useAllDaoPermissionsSpy.mockReset(); + useAllAllowedActionsSpy.mockReset(); useDialogContextSpy.mockReset(); - useCreateProposalFormContextSpy.mockReset(); + useDownloadProposalActionsSpy.mockReset(); getDaoPluginsSpy.mockReset(); }); diff --git a/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.tsx b/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.tsx index 7ef19bc3a0..ccb9181c63 100644 --- a/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.tsx +++ b/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.tsx @@ -1,24 +1,14 @@ import { addressUtils, type ProposalActionComponent } from '@aragon/gov-ui-kit'; -import { useCallback, useState } from 'react'; -import { useFormContext } from 'react-hook-form'; -import { useAllowedActions } from '@/modules/governance/api/executeSelectorsService'; -import type { IProposalAction } from '@/modules/governance/api/governanceService'; +import { useAllAllowedActions } from '@/modules/governance/api/executeSelectorsService'; import { useAllDaoPermissions, useDao } from '@/shared/api/daoService'; import { useTranslations } from '@/shared/components/translationsProvider'; import { useDaoChain } from '@/shared/hooks/useDaoChain'; import { daoUtils } from '@/shared/utils/daoUtils'; -import { monitoringUtils } from '@/shared/utils/monitoringUtils'; -import type { IProposalCreateAction } from '../../../dialogs/publishProposalDialog'; +import { useDownloadProposalActions } from '../../../hooks/useDownloadProposalActions'; import { useProposalActionsField } from '../../../hooks/useProposalActionsField'; -import { proposalActionPreparationUtils } from '../../../utils/proposalActionPreparationUtils'; -import { proposalActionsImportExportUtils } from '../../../utils/proposalActionsImportExportUtils'; import { ActionComposer, actionComposerUtils } from '../../actionComposer'; import { ProposalActionsEditList } from '../../proposalActionsEditList'; -import type { - ICreateProposalFormData, - IProposalActionData, -} from '../createProposalFormDefinitions'; -import { useCreateProposalFormContext } from '../createProposalFormProvider'; +import type { IProposalActionData } from '../createProposalFormDefinitions'; import { coreCustomActionComponents } from './coreCustomActionComponents'; export interface ICreateProposalFormActionsProps { @@ -64,10 +54,6 @@ export const CreateProposalFormActions: React.FC< const { t } = useTranslations(); const { chainId } = useDaoChain({ daoId }); - const { getValues } = useFormContext(); - - const { prepareActions } = useCreateProposalFormContext(); - const { actionsMerged, handleAddAction, @@ -75,55 +61,16 @@ export const CreateProposalFormActions: React.FC< getArrayControls, } = useProposalActionsField(); - const { data: allowedActionsData } = useAllowedActions( - { - urlParams: { network: dao!.network, pluginAddress }, - queryParams: { pageSize: 50 }, - }, + const { data: allowedActions } = useAllAllowedActions( + { urlParams: { network: dao!.network, pluginAddress }, chainId }, { enabled: hasConditionalPermissions }, ); const { data: daoPermissions } = useAllDaoPermissions({ urlParams: { network: dao!.network, daoAddress: targetDaoAddress }, }); - const allowedActions = allowedActionsData?.pages.flatMap( - (page) => page.data, - ); - - const [isDownloadPinning, setIsDownloadPinning] = useState(false); - const [hasDownloadPinErrors, setHasDownloadPinErrors] = useState(false); - - const handleDownloadActions = useCallback(async () => { - setIsDownloadPinning(true); - setHasDownloadPinErrors(false); - - try { - const currentActions = getValues('actions') ?? []; - - // Prepare actions using registered prepare functions - const preparedActions = - await proposalActionPreparationUtils.prepareActions({ - actions: currentActions as IProposalCreateAction[], - prepareActions, - }); - - proposalActionsImportExportUtils.downloadActionsAsJSON( - preparedActions as unknown as IProposalAction[], - `dao-${daoId}-actions.json`, - ); - } catch (error) { - monitoringUtils.logError(error, { - context: { - daoId, - message: - 'Failed to pin or download proposal actions for DAO', - }, - }); - setHasDownloadPinErrors(true); - } finally { - setIsDownloadPinning(false); - } - }, [daoId, getValues, prepareActions]); + const { isPinning, hasPinErrors, handleDownloadActions } = + useDownloadProposalActions({ daoId }); const { pluginComponents } = actionComposerUtils.getDaoPluginActions(dao); const { components: permissionActionComponents } = @@ -160,8 +107,8 @@ export const CreateProposalFormActions: React.FC< daoId={targetDaoId} daoPermissions={daoPermissions} hasActions={hasActions} - hasPinErrors={hasDownloadPinErrors} - isPinning={isDownloadPinning} + hasPinErrors={hasPinErrors} + isPinning={isPinning} onAddAction={handleAddAction} onDownloadActions={handleDownloadActions} onRemoveAllActions={handleRemoveAllActions} diff --git a/apps/app/src/modules/governance/components/createProposalForm/createProposalFormProvider/createProposalFormProvider.tsx b/apps/app/src/modules/governance/components/createProposalForm/createProposalFormProvider/createProposalFormProvider.tsx index ab6392b7f3..cb8710e80b 100644 --- a/apps/app/src/modules/governance/components/createProposalForm/createProposalFormProvider/createProposalFormProvider.tsx +++ b/apps/app/src/modules/governance/components/createProposalForm/createProposalFormProvider/createProposalFormProvider.tsx @@ -4,6 +4,7 @@ import type { PrepareProposalActionFunction, PrepareProposalActionMap, } from '@/modules/governance/dialogs/publishProposalDialog'; +import type { IDaoPlugin } from '@/shared/api/daoService'; type AddPrepareActionFunction< TAction extends IProposalCreateAction = IProposalCreateAction, @@ -24,6 +25,12 @@ export interface ICreateProposalFormContext< * Callback to update the prepare-action maps for the given proposal action type. */ addPrepareAction: AddPrepareActionFunction; + /** + * Plugin creating the proposal, used by the action components needing the context of the process + * they are composed for. Undefined when the actions are composed outside of the create-proposal + * flow, e.g. by the execute-actions form or by the nested actions dialog. + */ + processPlugin?: IDaoPlugin; } const createProposalFormContext = @@ -47,5 +54,6 @@ export const useCreateProposalFormContext = < values.prepareActions as PrepareProposalActionMap, addPrepareAction: values.addPrepareAction as AddPrepareActionFunction, + processPlugin: values.processPlugin, }; }; diff --git a/apps/app/src/modules/governance/components/nestedActionsList/nestedActionsList.test.tsx b/apps/app/src/modules/governance/components/nestedActionsList/nestedActionsList.test.tsx index 62f2488777..d3fd60803d 100644 --- a/apps/app/src/modules/governance/components/nestedActionsList/nestedActionsList.test.tsx +++ b/apps/app/src/modules/governance/components/nestedActionsList/nestedActionsList.test.tsx @@ -66,24 +66,25 @@ describe(' component', () => { ...overrides, }); - it('renders one item per decoded sub-action when length matches the outer tuple', () => { + const firstTarget = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const secondTarget = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + const outerParams = (value: unknown[]) => [ + { name: '_actions', type: 'tuple[]', value }, + ]; + + it('renders one item per decoded sub-action when they match the outer tuple', () => { const rawActions = [ - generateAction({ type: 'Foo' }), - generateAction({ type: 'Bar' }), + generateAction({ type: 'Foo', to: firstTarget }), + generateAction({ type: 'Bar', to: secondTarget }), ]; render( createTestComponent({ - outerParams: [ - { - name: '_actions', - type: 'tuple[]', - value: [ - { to: '0xa', value: '0', data: '0x' }, - { to: '0xb', value: '0', data: '0x' }, - ], - }, - ], + outerParams: outerParams([ + { to: firstTarget, value: '0', data: '0x' }, + { to: secondTarget, value: '0', data: '0x' }, + ]), rawActions, }), ); @@ -97,18 +98,12 @@ describe(' component', () => { it('falls back to raw-calldata stubs when decoded length disagrees with the outer tuple', () => { render( createTestComponent({ - outerParams: [ - { - name: '_actions', - type: 'tuple[]', - value: [ - { to: '0xa', value: '0', data: '0x' }, - { to: '0xb', value: '0', data: '0x' }, - { to: '0xc', value: '0', data: '0x' }, - ], - }, - ], - rawActions: [generateAction()], + outerParams: outerParams([ + { to: firstTarget, value: '0', data: '0x' }, + { to: secondTarget, value: '0', data: '0x' }, + { to: firstTarget, value: '0', data: '0x' }, + ]), + rawActions: [generateAction({ to: firstTarget })], }), ); @@ -119,6 +114,21 @@ describe(' component', () => { }); }); + it('falls back to raw-calldata stubs when a decoded sub-action targets a different address than the tuple', () => { + render( + createTestComponent({ + outerParams: outerParams([ + { to: firstTarget, value: '0', data: '0x' }, + ]), + rawActions: [generateAction({ type: 'Foo', to: secondTarget })], + }), + ); + + expect(screen.getByTestId('nested-item')).toHaveTextContent( + 'RAW_CALLDATA', + ); + }); + it('renders nothing while the DAO is loading', () => { useDaoSpy.mockReturnValue( generateReactQueryResultSuccessWithData( @@ -127,14 +137,10 @@ describe(' component', () => { ); const { container } = render( createTestComponent({ - outerParams: [ - { - name: '_actions', - type: 'tuple[]', - value: [{ to: '0xa', value: '0', data: '0x' }], - }, - ], - rawActions: [generateAction()], + outerParams: outerParams([ + { to: firstTarget, value: '0', data: '0x' }, + ]), + rawActions: [generateAction({ to: firstTarget })], }), ); expect(container).toBeEmptyDOMElement(); diff --git a/apps/app/src/modules/governance/components/nestedActionsList/nestedActionsList.tsx b/apps/app/src/modules/governance/components/nestedActionsList/nestedActionsList.tsx index 1467001728..4e12caa5d5 100644 --- a/apps/app/src/modules/governance/components/nestedActionsList/nestedActionsList.tsx +++ b/apps/app/src/modules/governance/components/nestedActionsList/nestedActionsList.tsx @@ -3,22 +3,23 @@ import { type IProposalActionInputDataParameter, ProposalActions, - ProposalActionTypeNoBasicView, } from '@aragon/gov-ui-kit'; import { useDao } from '@/shared/api/daoService'; import type { IProposalAction } from '../../api/governanceService'; +import type { IRawActionTuple } from '../../types'; import { proposalActionUtils } from '../../utils/proposalActionUtils'; import { ProposalActionsItem } from '../proposalActionsItem'; export interface INestedActionsListProps { /** * Decoded input parameters of the outer wrapper action (e.g. `createProposal`/`execute`). Used to read the raw - * `_actions` tuple as a fallback when the decoded sub-actions are missing or out of sync. + * `_actions` tuple, both to check the decoded sub-actions against and as a fallback when they are missing or out + * of sync. */ outerParams: IProposalActionInputDataParameter[]; /** - * Decoded sub-actions emitted by the backend. When the length differs from the raw `_actions` tuple, raw-calldata - * stubs are rendered instead. + * Decoded sub-actions emitted by the backend. When they do not describe the same calls as the raw `_actions` + * tuple, raw-calldata stubs are rendered instead. */ rawActions: IProposalAction[] | undefined; /** @@ -31,22 +32,6 @@ export interface INestedActionsListProps { chainId?: number; } -interface IRawActionTuple { - to: string; - value: string; - data: string; -} - -const buildRawActionStubs = (tuple: IRawActionTuple[]): IProposalAction[] => - tuple.map((entry) => ({ - from: '', - to: entry.to, - data: entry.data, - value: entry.value, - type: ProposalActionTypeNoBasicView.RAW_CALLDATA, - inputData: null, - })); - export const NestedActionsList: React.FC = (props) => { const { outerParams, rawActions, daoId, chainId } = props; @@ -61,12 +46,10 @@ export const NestedActionsList: React.FC = (props) => { | IRawActionTuple[] | undefined) ?? []; - const hasDecodedMismatch = - rawActions == null || rawActions.length !== rawTuple.length; - - const actionsToRender = hasDecodedMismatch - ? buildRawActionStubs(rawTuple) - : rawActions; + const actionsToRender = proposalActionUtils.resolveNestedActions( + rawActions, + rawTuple, + ); if (actionsToRender.length === 0) { return null; diff --git a/apps/app/src/modules/governance/components/proposalActionsEditor/proposalActionsEditor.tsx b/apps/app/src/modules/governance/components/proposalActionsEditor/proposalActionsEditor.tsx index b3302d3978..611d3c9f04 100644 --- a/apps/app/src/modules/governance/components/proposalActionsEditor/proposalActionsEditor.tsx +++ b/apps/app/src/modules/governance/components/proposalActionsEditor/proposalActionsEditor.tsx @@ -1,4 +1,5 @@ import { invariant, type ProposalActionComponent } from '@aragon/gov-ui-kit'; +import type { IAllowedAction } from '@/modules/governance/api/executeSelectorsService'; import { type Network, useAllDaoPermissions, @@ -6,6 +7,7 @@ import { } from '@/shared/api/daoService'; import { useTranslations } from '@/shared/components/translationsProvider'; import { useDaoChain } from '@/shared/hooks/useDaoChain'; +import { useDownloadProposalActions } from '../../hooks/useDownloadProposalActions'; import { useProposalActionsField } from '../../hooks/useProposalActionsField'; import { ActionComposer, actionComposerUtils } from '../actionComposer'; import type { IProposalActionData } from '../createProposalForm'; @@ -26,6 +28,10 @@ export interface IProposalActionsEditorProps { * Action types to hide from the action composer, e.g. to stop an action from being nested into itself. */ excludeActionTypes?: string[]; + /** + * Actions the composer restricts its offering to. Leave undefined to offer every action. + */ + allowedActions?: IAllowedAction[]; } /** @@ -36,7 +42,7 @@ export interface IProposalActionsEditorProps { export const ProposalActionsEditor: React.FC = ( props, ) => { - const { daoId, network, excludeActionTypes } = props; + const { daoId, network, excludeActionTypes, allowedActions } = props; invariant( daoId != null || network != null, @@ -57,6 +63,9 @@ export const ProposalActionsEditor: React.FC = ( getArrayControls, } = useProposalActionsField(); + const { isPinning, hasPinErrors, handleDownloadActions } = + useDownloadProposalActions({ daoId }); + const { data: daoPermissions } = useAllDaoPermissions( { urlParams: { @@ -98,12 +107,16 @@ export const ProposalActionsEditor: React.FC = ( /> {showActionComposer ? ( ) : ( diff --git a/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.api.ts b/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.api.ts index ce19a43507..b35c6aebef 100644 --- a/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.api.ts +++ b/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.api.ts @@ -1,11 +1,26 @@ +import type { Network } from '@/shared/api/daoService'; import type { IDialogComponentProps } from '@/shared/components/dialogProvider'; import type { IProposalActionData } from '../../components/createProposalForm'; export interface INestedActionsDialogParams { /** * ID of the DAO the nested actions are composed for. + * Host DAO -> process -> create proposal -> actions (action1, action2WithNstedActions, action 3) + * Nested actions could be cross-chain, so it is basically configured for another DAO on another chain. + * Host DAO's process can define allowed actions also for other chains. */ - daoId: string; + hostDaoId: string; + /** + * Address of the process plugin restricting the actions that can be composed. When omitted no allowed + * actions are fetched and every action is offered by the composer. + */ + processPluginAddress?: string; + /** + * Network the nested actions are composed for, defaults to the network of the DAO. Set it when + * the actions are executed on another chain than the DAO, e.g. when they are forwarded to a + * cross-chain controller. + */ + crossChainNetwork?: Network; /** * Actions to seed the isolated dialog form with, used to edit a previously composed selection. */ diff --git a/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.test.tsx b/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.test.tsx index 72d26ffe8e..779ec4ba82 100644 --- a/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.test.tsx +++ b/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.test.tsx @@ -3,17 +3,20 @@ import userEvent from '@testing-library/user-event'; import { useFormContext } from 'react-hook-form'; import * as daoService from '@/shared/api/daoService'; import * as dialogProvider from '@/shared/components/dialogProvider'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; import { generateDao, generateDialogContext, generateReactQueryResultSuccessWithData, } from '@/shared/testUtils'; import { monitoringUtils } from '@/shared/utils/monitoringUtils'; +import type { IAllowedAction } from '../../api/executeSelectorsService'; +import * as executeSelectorsService from '../../api/executeSelectorsService'; import type { IProposalActionData } from '../../components/createProposalForm'; import type { IProposalActionsEditorProps } from '../../components/proposalActionsEditor'; import * as proposalActionsEditorModule from '../../components/proposalActionsEditor'; import { GovernanceDialogId } from '../../constants/governanceDialogId'; -import { generateProposalAction } from '../../testUtils'; +import { generateAllowedAction, generateProposalAction } from '../../testUtils'; import { proposalActionPreparationUtils } from '../../utils/proposalActionPreparationUtils'; import { proposalActionsImportExportUtils } from '../../utils/proposalActionsImportExportUtils'; import { NestedActionsDialog } from './nestedActionsDialog'; @@ -111,6 +114,9 @@ const ProposalActionsEditorStub: React.FC = ( data-action-dao-ids={JSON.stringify( getValues('actions').map((action) => action.daoId), )} + data-allowed-action-targets={JSON.stringify( + props.allowedActions?.map((action) => action.target), + )} data-dao-id={props.daoId} data-exclude-action-types={JSON.stringify(props.excludeActionTypes)} data-testid="actions-editor" @@ -138,8 +144,24 @@ describe(' component', () => { ); const useDaoSpy = jest.spyOn(daoService, 'useDao'); const logErrorSpy = jest.spyOn(monitoringUtils, 'logError'); + const useAllAllowedActionsSpy = jest.spyOn( + executeSelectorsService, + 'useAllAllowedActions', + ); + + // The hook keeps its data undefined until the full allowlist is known, which is also what a + // disabled query resolves to. + const mockAllowedActions = (data?: IAllowedAction[]) => + useAllAllowedActionsSpy.mockReturnValue( + generateReactQueryResultSuccessWithData( + data, + ) as unknown as ReturnType< + typeof executeSelectorsService.useAllAllowedActions + >, + ); beforeEach(() => { + mockAllowedActions(); useDialogContextSpy.mockReturnValue(generateDialogContext()); useDaoSpy.mockReturnValue( generateReactQueryResultSuccessWithData( @@ -161,13 +183,14 @@ describe(' component', () => { decodeActionsSpy.mockReset(); proposalActionsEditorSpy.mockReset(); logErrorSpy.mockReset(); + useAllAllowedActionsSpy.mockReset(); }); const createTestComponent = ( params?: Partial, ) => { const completeParams: INestedActionsDialogParams = { - daoId: DAO_ID, + hostDaoId: DAO_ID, initialActions: [], onSubmit: jest.fn(), ...params, @@ -201,6 +224,54 @@ describe(' component', () => { expect(decodeActionsSpy).not.toHaveBeenCalled(); }); + it('fetches the allowed actions on the plugin network for the chain the actions are composed for', () => { + createTestComponent({ + processPluginAddress: '0xplugin', + crossChainNetwork: daoService.Network.BASE_MAINNET, + }); + + expect(useAllAllowedActionsSpy).toHaveBeenLastCalledWith( + { + urlParams: { network: DAO.network, pluginAddress: '0xplugin' }, + chainId: networkDefinitions[daoService.Network.BASE_MAINNET].id, + }, + { enabled: true }, + ); + }); + + it('forwards the allowed actions of the plugin to the editor', () => { + mockAllowedActions([generateAllowedAction({ target: '0xallowed' })]); + + createTestComponent({ processPluginAddress: '0xplugin' }); + + expect( + screen.getByTestId('actions-editor').dataset.allowedActionTargets, + ).toEqual(JSON.stringify(['0xallowed'])); + }); + + // The composer reads the allowlist on mount and offers every action when it is undefined, so + // rendering it early would leave the restricted actions unrestricted. + it('hides the editor until the allowed actions of the plugin are resolved', () => { + mockAllowedActions(undefined); + + createTestComponent({ processPluginAddress: '0xplugin' }); + + expect(screen.queryByTestId('actions-editor')).not.toBeInTheDocument(); + expect( + screen.getByText( + 'app.governance.nestedActionsDialog.loadingAllowedActions', + ), + ).toBeInTheDocument(); + }); + + it('offers every action when no plugin restricts them', () => { + createTestComponent(); + + expect( + screen.getByTestId('actions-editor').dataset.allowedActionTargets, + ).toBeUndefined(); + }); + it('decodes the initial actions before seeding the form when none of them carry input data', async () => { const rawAction = generateRawActionData({ to: '0xraw', diff --git a/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.tsx b/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.tsx index 5b7d3bc568..e33dfd2e90 100644 --- a/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.tsx +++ b/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.tsx @@ -3,10 +3,12 @@ import { AlertInline, Dialog, invariant } from '@aragon/gov-ui-kit'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; -import { useDao } from '@/shared/api/daoService'; +import { type Network, useDao } from '@/shared/api/daoService'; import { useDialogContext } from '@/shared/components/dialogProvider'; import { useTranslations } from '@/shared/components/translationsProvider'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; import { monitoringUtils } from '@/shared/utils/monitoringUtils'; +import { useAllAllowedActions } from '../../api/executeSelectorsService'; import type { IProposalActionData } from '../../components/createProposalForm'; import { CreateProposalFormProvider } from '../../components/createProposalForm'; import { ProposalActionsEditor } from '../../components/proposalActionsEditor'; @@ -37,12 +39,42 @@ export const NestedActionsDialog: React.FC = ( 'NestedActionsDialog: required parameters must be set.', ); - const { daoId, initialActions, excludeActionTypes, onSubmit } = - location.params; + const { + hostDaoId, + processPluginAddress, + crossChainNetwork, + initialActions, + excludeActionTypes, + onSubmit, + } = location.params; const { t } = useTranslations(); const { close } = useDialogContext(); - const { data: dao } = useDao({ urlParams: { id: daoId } }); + const { data: hostDao } = useDao({ urlParams: { id: hostDaoId } }); + + const resolvedNetwork = crossChainNetwork ?? hostDao?.network; + + const isActionsRestricted = processPluginAddress != null; + const composerChainId = + resolvedNetwork != null + ? networkDefinitions[resolvedNetwork].id + : undefined; + + const { data: allowedActions } = useAllAllowedActions( + { + urlParams: { + network: hostDao?.network as Network, + pluginAddress: processPluginAddress ?? '', + }, + chainId: composerChainId, + }, + { enabled: hostDao != null && isActionsRestricted }, + ); + + // The composer reads the allowlist on mount only and treats an undefined list as "every action is + // allowed", therefore it must not be rendered before the allowed actions of the process resolve. + const isLoadingAllowedActions = + isActionsRestricted && allowedActions == null; const [prepareActions, setPrepareActions] = useState({}); @@ -80,7 +112,11 @@ export const NestedActionsDialog: React.FC = ( const hasDecodingStartedRef = useRef(false); useEffect(() => { - if (!requiresDecoding || dao == null || hasDecodingStartedRef.current) { + if (!requiresDecoding || hasDecodingStartedRef.current) { + return; + } + + if (hostDao == null) { return; } @@ -88,6 +124,11 @@ export const NestedActionsDialog: React.FC = ( const decodeInitialActions = async () => { try { + invariant( + resolvedNetwork != null, + 'decodeInitialActions: resolvedNetwork not found', + ); + const decodedActions = await proposalActionsImportExportUtils.decodeActions( initialActions.map(({ to, value, data }) => ({ @@ -95,23 +136,26 @@ export const NestedActionsDialog: React.FC = ( value, data, })), - dao.network, - dao, + resolvedNetwork, + crossChainNetwork ? undefined : hostDao, ); - // The decoder returns the backend action shape, which carries no `daoId`. Attach it - // as the composer and the action import do, since the basic views read it (e.g. a - // transfer resolves its DAO through `useDao({ id: action.daoId })`). reset({ - actions: decodedActions.map( - (action) => - ({ ...action, daoId }) as IProposalActionData, - ), + actions: crossChainNetwork + ? decodedActions + : decodedActions.map( + (action) => + ({ + ...action, + daoId: hostDaoId, + }) as IProposalActionData, + ), }); } catch (error) { monitoringUtils.logError(error, { context: { - daoId, + hostDaoId, + crossChainNetwork, message: 'Failed to decode the nested proposal actions', }, }); @@ -122,7 +166,17 @@ export const NestedActionsDialog: React.FC = ( }; void decodeInitialActions(); - }, [dao, daoId, initialActions, requiresDecoding, reset]); + }, [ + hostDao, + hostDaoId, + crossChainNetwork, + resolvedNetwork, + initialActions, + requiresDecoding, + reset, + ]); + + const isLoadingActions = isDecoding || isLoadingAllowedActions; const handleClose = () => close(location.id); @@ -148,7 +202,8 @@ export const NestedActionsDialog: React.FC = ( } catch (error) { monitoringUtils.logError(error, { context: { - daoId, + hostDaoId, + crossChainNetwork, message: 'Failed to prepare the nested proposal actions', }, }); @@ -158,7 +213,6 @@ export const NestedActionsDialog: React.FC = ( } }; - // TODO: enable running without DAO ID return ( @@ -170,17 +224,21 @@ export const NestedActionsDialog: React.FC = ( title={t('app.governance.nestedActionsDialog.title')} /> - {isDecoding ? ( + {isLoadingActions ? ( ) : ( )} {hasDecodeError && ( @@ -203,7 +261,7 @@ export const NestedActionsDialog: React.FC = ( { + const prepareActionsSpy = jest.spyOn( + proposalActionPreparationUtils, + 'prepareActions', + ); + const downloadActionsSpy = jest.spyOn( + proposalActionsImportExportUtils, + 'downloadActionsAsJSON', + ); + const logErrorSpy = jest.spyOn(monitoringUtils, 'logError'); + + beforeEach(() => { + prepareActionsSpy.mockImplementation(({ actions }) => + Promise.resolve( + actions.map((action) => ({ ...action, data: '0x' as const })), + ), + ); + downloadActionsSpy.mockImplementation(() => undefined); + logErrorSpy.mockImplementation(() => undefined); + }); + + afterEach(() => { + prepareActionsSpy.mockReset(); + downloadActionsSpy.mockReset(); + logErrorSpy.mockReset(); + }); + + const generateAction = ( + action?: Partial, + ): IProposalActionData => + ({ + type: 'transfer', + meta: undefined, + ...action, + }) as IProposalActionData; + + const createWrapper = ( + actions: IProposalActionData[] = [], + prepareActions: PrepareProposalActionMap = {}, + ) => { + const Wrapper: React.FC<{ children?: ReactNode }> = (props) => { + const { children } = props; + + const formMethods = useForm({ defaultValues: { actions } }); + const contextValues = { + prepareActions, + addPrepareAction: jest.fn(), + }; + + return ( + + + {children} + + + ); + }; + + return Wrapper; + }; + + it('returns an idle state on mount', () => { + const { result } = renderHook(() => useDownloadProposalActions(), { + wrapper: createWrapper(), + }); + + expect(result.current.isPinning).toBeFalsy(); + expect(result.current.hasPinErrors).toBeFalsy(); + }); + + it('prepares the form actions and downloads them into a DAO-scoped file', async () => { + const actions = [ + generateAction(), + generateAction({ type: 'withdraw' }), + ]; + const prepareActions: PrepareProposalActionMap = {}; + const { result } = renderHook( + () => useDownloadProposalActions({ daoId: 'ethereum-0x123' }), + { wrapper: createWrapper(actions, prepareActions) }, + ); + + await act(() => result.current.handleDownloadActions()); + + expect(prepareActionsSpy).toHaveBeenCalledWith({ + actions: expect.arrayContaining([ + expect.objectContaining({ type: 'transfer' }), + expect.objectContaining({ type: 'withdraw' }), + ]), + prepareActions, + }); + expect(downloadActionsSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ type: 'transfer' }), + ]), + 'dao-ethereum-0x123-actions.json', + ); + expect(result.current.isPinning).toBeFalsy(); + expect(result.current.hasPinErrors).toBeFalsy(); + }); + + it('falls back to a generic file name when the actions are composed outside a DAO context', async () => { + const { result } = renderHook(() => useDownloadProposalActions(), { + wrapper: createWrapper([generateAction()]), + }); + + await act(() => result.current.handleDownloadActions()); + + expect(downloadActionsSpy).toHaveBeenCalledWith( + expect.anything(), + 'actions.json', + ); + }); + + it('sets the pinning flag while the actions are being prepared', async () => { + prepareActionsSpy.mockReturnValue(new Promise(() => undefined)); + const { result } = renderHook(() => useDownloadProposalActions(), { + wrapper: createWrapper([generateAction()]), + }); + + act(() => { + void result.current.handleDownloadActions(); + }); + + await waitFor(() => expect(result.current.isPinning).toBeTruthy()); + expect(downloadActionsSpy).not.toHaveBeenCalled(); + }); + + it('flags the error and logs it when the action preparation fails', async () => { + const error = new Error('prepare-error'); + prepareActionsSpy.mockRejectedValue(error); + const { result } = renderHook( + () => useDownloadProposalActions({ daoId: 'ethereum-0x123' }), + { wrapper: createWrapper([generateAction()]) }, + ); + + await act(() => result.current.handleDownloadActions()); + + expect(downloadActionsSpy).not.toHaveBeenCalled(); + expect(logErrorSpy).toHaveBeenCalledWith( + error, + expect.objectContaining({ + context: expect.objectContaining({ daoId: 'ethereum-0x123' }), + }), + ); + expect(result.current.hasPinErrors).toBeTruthy(); + expect(result.current.isPinning).toBeFalsy(); + }); +}); diff --git a/apps/app/src/modules/governance/hooks/useDownloadProposalActions/useDownloadProposalActions.ts b/apps/app/src/modules/governance/hooks/useDownloadProposalActions/useDownloadProposalActions.ts new file mode 100644 index 0000000000..188a0e97b9 --- /dev/null +++ b/apps/app/src/modules/governance/hooks/useDownloadProposalActions/useDownloadProposalActions.ts @@ -0,0 +1,89 @@ +import { useCallback, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import type { IProposalAction } from '@/modules/governance/api/governanceService'; +import { monitoringUtils } from '@/shared/utils/monitoringUtils'; +import type { + ICreateProposalFormData, + IProposalActionData, +} from '../../components/createProposalForm'; +import { useCreateProposalFormContext } from '../../components/createProposalForm'; +import type { IProposalCreateAction } from '../../dialogs/publishProposalDialog'; +import { proposalActionPreparationUtils } from '../../utils/proposalActionPreparationUtils'; +import { proposalActionsImportExportUtils } from '../../utils/proposalActionsImportExportUtils'; + +export interface IUseDownloadProposalActionsParams { + /** + * ID of the DAO the actions are composed for. Used to name the downloaded file and to enrich the + * error context. Omit when the actions are composed outside a DAO context. + */ + daoId?: string; +} + +/** + * Downloads the actions of the surrounding form as a JSON file, running the registered prepare + * functions first so that actions needing async work (e.g. IPFS pinning) export their final calldata. + * Must be called within a form context holding an `actions` array and a `CreateProposalFormProvider`. + */ +export const useDownloadProposalActions = ( + params: IUseDownloadProposalActionsParams = {}, +) => { + const { daoId } = params; + + const { getValues } = + useFormContext>(); + const { prepareActions } = useCreateProposalFormContext(); + + const [isPinning, setIsPinning] = useState(false); + const [hasPinErrors, setHasPinErrors] = useState(false); + + const handleDownloadActions = useCallback(async () => { + setIsPinning(true); + setHasPinErrors(false); + + try { + const currentActions: IProposalActionData[] = + getValues('actions') ?? []; + + // Prepare actions using registered prepare functions + const preparedActions = + await proposalActionPreparationUtils.prepareActions({ + actions: currentActions as IProposalCreateAction[], + prepareActions, + }); + + const fileName = + daoId != null ? `dao-${daoId}-actions.json` : 'actions.json'; + + proposalActionsImportExportUtils.downloadActionsAsJSON( + preparedActions as unknown as IProposalAction[], + fileName, + ); + } catch (error) { + monitoringUtils.logError(error, { + context: { + daoId, + message: + 'Failed to pin or download proposal actions for DAO', + }, + }); + setHasPinErrors(true); + } finally { + setIsPinning(false); + } + }, [daoId, getValues, prepareActions]); + + return { + /** + * Whether the actions are currently being prepared (e.g. pinned to IPFS) for the download. + */ + isPinning, + /** + * Whether the last download attempt failed while preparing the actions. + */ + hasPinErrors, + /** + * Prepares and downloads the current actions as a JSON file. + */ + handleDownloadActions, + }; +}; diff --git a/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx b/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx index 98d7fc2a90..00111f990e 100644 --- a/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx +++ b/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx @@ -73,8 +73,8 @@ export const CreateProposalPageClient: React.FC< ); const contextValues = useMemo( - () => ({ prepareActions, addPrepareAction }), - [prepareActions, addPrepareAction], + () => ({ prepareActions, addPrepareAction, processPlugin: plugin }), + [prepareActions, addPrepareAction, plugin], ); const handleFormSubmit = (values: ICreateProposalFormData) => { diff --git a/apps/app/src/modules/governance/testUtils/generators/allowedAction.ts b/apps/app/src/modules/governance/testUtils/generators/allowedAction.ts new file mode 100644 index 0000000000..d7733b6d08 --- /dev/null +++ b/apps/app/src/modules/governance/testUtils/generators/allowedAction.ts @@ -0,0 +1,12 @@ +import type { IAllowedAction } from '../../api/executeSelectorsService'; + +export const generateAllowedAction = ( + action?: Partial, +): IAllowedAction => ({ + selector: '0x12345678', + target: '0x123', + isAllowed: true, + id: 'test-id', + conditionAddress: '0xCondition', + ...action, +}); diff --git a/apps/app/src/modules/governance/testUtils/generators/index.ts b/apps/app/src/modules/governance/testUtils/generators/index.ts index f18eb2bdd4..a4f882a3ca 100644 --- a/apps/app/src/modules/governance/testUtils/generators/index.ts +++ b/apps/app/src/modules/governance/testUtils/generators/index.ts @@ -1,3 +1,4 @@ +export * from './allowedAction'; export * from './createProposalFormData'; export * from './member'; export * from './memberMetrics'; diff --git a/apps/app/src/modules/governance/types/index.ts b/apps/app/src/modules/governance/types/index.ts index 4035f04ab2..13cc914aaf 100644 --- a/apps/app/src/modules/governance/types/index.ts +++ b/apps/app/src/modules/governance/types/index.ts @@ -12,5 +12,6 @@ export type { IMemberExistsResult } from './memberExistsResult'; export type { INormalizeActionsParams } from './normalizeActionsParams'; export type { IPermissionCheckGuardParams } from './permissionCheckGuardParams'; export type { IPermissionCheckGuardResult } from './permissionCheckGuardResult'; +export type { IRawActionTuple } from './rawActionTuple'; export type { ISubmitVoteProps } from './submitVoteProps'; export type { IUsePluginMemberStatsParams } from './usePluginMemberStatsParams'; diff --git a/apps/app/src/modules/governance/types/rawActionTuple.ts b/apps/app/src/modules/governance/types/rawActionTuple.ts new file mode 100644 index 0000000000..65af344a00 --- /dev/null +++ b/apps/app/src/modules/governance/types/rawActionTuple.ts @@ -0,0 +1,18 @@ +/** + * Raw `(to, value, data)` tuple of a nested action, as carried by the calldata of a wrapper action (e.g. `execute`, + * `createProposal`, or a cross-chain forwarded message). + */ +export interface IRawActionTuple { + /** + * Target address of the action. + */ + to: string; + /** + * Native value sent with the action. + */ + value: string; + /** + * Calldata of the action. + */ + data: string; +} diff --git a/apps/app/src/modules/governance/utils/proposalActionUtils/proposalActionUtils.test.tsx b/apps/app/src/modules/governance/utils/proposalActionUtils/proposalActionUtils.test.tsx index 0f6bc9c8e9..493e30a62d 100644 --- a/apps/app/src/modules/governance/utils/proposalActionUtils/proposalActionUtils.test.tsx +++ b/apps/app/src/modules/governance/utils/proposalActionUtils/proposalActionUtils.test.tsx @@ -387,4 +387,88 @@ describe('proposalActionUtils', () => { expect(result).toBeUndefined(); }); }); + + describe('resolveNestedActions', () => { + const rawTuple = [ + { + to: '0xE5e5f0c2e1B5F1b8f2C0a1D3a4B5c6D7e8F90123', + value: '1000', + data: '0xDEADBEEF', + }, + ]; + + const matchingAction = generateProposalAction({ + to: rawTuple[0].to.toLowerCase(), + value: rawTuple[0].value, + data: rawTuple[0].data.toLowerCase(), + type: ProposalActionType.TRANSFER, + }); + + const expectedStubs = [ + { + from: '', + to: rawTuple[0].to, + value: rawTuple[0].value, + data: rawTuple[0].data, + type: 'RAW_CALLDATA', + inputData: null, + }, + ]; + + it('keeps the decoded actions when they describe the same calls as the tuple', () => { + expect( + proposalActionUtils.resolveNestedActions( + [matchingAction], + rawTuple, + ), + ).toEqual([matchingAction]); + }); + + it('keeps the decoded actions when only the notation of the value differs', () => { + const action = generateProposalAction({ + ...matchingAction, + value: '0x3e8', + }); + + expect( + proposalActionUtils.resolveNestedActions([action], rawTuple), + ).toEqual([action]); + }); + + it('falls back to raw stubs when the decoded actions are missing', () => { + expect( + proposalActionUtils.resolveNestedActions(undefined, rawTuple), + ).toEqual(expectedStubs); + }); + + it('falls back to raw stubs when the counts disagree', () => { + expect( + proposalActionUtils.resolveNestedActions( + [matchingAction, matchingAction], + rawTuple, + ), + ).toEqual(expectedStubs); + }); + + it.each([ + { + field: 'to', + value: '0x1111111111111111111111111111111111111111', + }, + { field: 'value', value: '999' }, + { field: 'data', value: '0xfeedface' }, + ])('falls back to raw stubs when the decoded $field disagrees with the tuple', ({ + field, + value, + }) => { + const action = generateProposalAction({ + ...matchingAction, + [field]: value, + }); + + expect( + proposalActionUtils.resolveNestedActions([action], rawTuple), + ).toEqual(expectedStubs); + }); + }); }); diff --git a/apps/app/src/modules/governance/utils/proposalActionUtils/proposalActionUtils.ts b/apps/app/src/modules/governance/utils/proposalActionUtils/proposalActionUtils.ts index 7f389ee766..6d21142611 100644 --- a/apps/app/src/modules/governance/utils/proposalActionUtils/proposalActionUtils.ts +++ b/apps/app/src/modules/governance/utils/proposalActionUtils/proposalActionUtils.ts @@ -1,10 +1,12 @@ import { + addressUtils, ProposalActionType as GukProposalActionType, type IProposalAction as IGukProposalAction, type IProposalActionUpdateMetadata as IGukProposalActionUpdateMetadata, type IProposalActionWithdrawToken as IGukProposalActionWithdrawToken, type IProposalActionUpdateMetadataDaoMetadata, type IProposalActionUpdateMetadataDaoMetadataLink, + ProposalActionTypeNoBasicView, } from '@aragon/gov-ui-kit'; import { type AbiStateMutability, @@ -25,9 +27,77 @@ import type { IDao, IResource } from '@/shared/api/daoService'; import { ipfsUtils } from '@/shared/utils/ipfsUtils'; import { pluginRegistryUtils } from '@/shared/utils/pluginRegistryUtils'; import { GovernanceSlotId } from '../../constants/moduleSlots'; -import type { INormalizeActionsParams } from '../../types'; +import type { INormalizeActionsParams, IRawActionTuple } from '../../types'; class ProposalActionUtils { + /** + * Builds raw-calldata stubs out of a raw actions tuple, used as a fallback view when the decoded sub-actions of a + * wrapper action are missing or out of sync with the tuple. + */ + buildRawActionStubs = (tuple: IRawActionTuple[]): IProposalAction[] => + tuple.map((entry) => ({ + from: '', + to: entry.to, + data: entry.data, + value: entry.value, + type: ProposalActionTypeNoBasicView.RAW_CALLDATA, + inputData: null, + })); + + /** + * Compares two native-currency amounts written as strings, tolerating a difference in notation between the decoded + * action and the calldata it was decoded from (hex vs decimal, leading zeros). Falls back to an exact comparison + * when either side is not a number at all, so an unparseable amount counts as a mismatch rather than a match. + * @param value - Amount of the decoded action. + * @param otherValue - Amount carried by the raw tuple. + * @returns True when both amounts are the same. + */ + isSameActionValue = (value: string, otherValue: string): boolean => { + try { + return BigInt(value) === BigInt(otherValue); + } catch { + return value === otherValue; + } + }; + + /** + * Checks that a decoded action describes the same call as the raw tuple entry it was decoded from. The two come + * from different sources - the backend decodes the action, the app reads the tuple out of the calldata - so + * addresses and calldata are compared case-insensitively and the value numerically. + * @param action - The decoded action. + * @param rawEntry - The raw tuple entry at the same position. + * @returns True when the action matches the tuple entry. + */ + isSameAction = ( + action: IProposalAction, + rawEntry: IRawActionTuple, + ): boolean => + addressUtils.isAddressEqual(action.to, rawEntry.to) && + action.data.toLowerCase() === rawEntry.data.toLowerCase() && + this.isSameActionValue(action.value, rawEntry.value); + + /** + * Resolves the sub-actions of a wrapper action to render, falling back to raw-calldata stubs built from `rawTuple` + * when the decoded `subActions` are missing or do not describe the same calls as the tuple. Matching the count + * alone is not enough: the tuple is what actually executes, so a decoded action that names a different target, + * value or calldata must never be shown in its place. + */ + resolveNestedActions = ( + subActions: IProposalAction[] | undefined, + rawTuple: IRawActionTuple[], + ): IProposalAction[] => { + const matchesRawTuple = + subActions != null && + subActions.length === rawTuple.length && + subActions.every((action, index) => + this.isSameAction(action, rawTuple[index]), + ); + + return matchesRawTuple + ? subActions + : this.buildRawActionStubs(rawTuple); + }; + normalizeActions = ( actions: IProposalAction[], dao: IDao, diff --git a/apps/app/src/modules/governance/utils/proposalActionsImportExportUtils/proposalActionsImportExportUtils.test.ts b/apps/app/src/modules/governance/utils/proposalActionsImportExportUtils/proposalActionsImportExportUtils.test.ts new file mode 100644 index 0000000000..3f5d51534a --- /dev/null +++ b/apps/app/src/modules/governance/utils/proposalActionsImportExportUtils/proposalActionsImportExportUtils.test.ts @@ -0,0 +1,52 @@ +import { proposalActionsImportExportUtils } from './proposalActionsImportExportUtils'; + +describe('proposalActionsImportExportUtils', () => { + describe('exportActionsToJSON', () => { + const action = { + to: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + value: '0', + data: '0xdeadbeef', + }; + + it('exports the action fields', () => { + expect( + proposalActionsImportExportUtils.exportActionsToJSON([action]), + ).toEqual([{ to: action.to, value: '0', data: action.data }]); + }); + + it.each([ + '1234567891234567891', + '1000000000000000000000', + '9007199254740993', + ])('keeps the wei value %p exact, which a JS number would round', (value) => { + const [exported] = + proposalActionsImportExportUtils.exportActionsToJSON([ + { ...action, value }, + ]); + + expect(exported.value).toEqual(value); + // The downloaded file must not carry a rounded or exponential amount. + expect(JSON.stringify(exported.value)).toEqual(`"${value}"`); + }); + + it('stringifies a bigint value instead of narrowing it to a number', () => { + const value = BigInt('1234567891234567891'); + + const [exported] = + proposalActionsImportExportUtils.exportActionsToJSON([ + { ...action, value } as unknown as typeof action, + ]); + + expect(exported.value).toEqual('1234567891234567891'); + }); + + it('falls back to zero for a missing value, which the import path rejects as empty', () => { + const [exported] = + proposalActionsImportExportUtils.exportActionsToJSON([ + { ...action, value: '' }, + ]); + + expect(exported.value).toEqual('0'); + }); + }); +}); diff --git a/apps/app/src/modules/governance/utils/proposalActionsImportExportUtils/proposalActionsImportExportUtils.ts b/apps/app/src/modules/governance/utils/proposalActionsImportExportUtils/proposalActionsImportExportUtils.ts index 4b099bd236..80893b75f3 100644 --- a/apps/app/src/modules/governance/utils/proposalActionsImportExportUtils/proposalActionsImportExportUtils.ts +++ b/apps/app/src/modules/governance/utils/proposalActionsImportExportUtils/proposalActionsImportExportUtils.ts @@ -37,7 +37,8 @@ export interface IExportedAction { */ to: string; /** - * ETH value to send (in wei). + * ETH value to send (in wei). Always a string when exported, since a wei amount does not fit a JS number. A number + * is still accepted on import, for files written by other tools. */ value: number | string; /** @@ -46,6 +47,12 @@ export interface IExportedAction { data: string; } +/** + * Minimal shape an action must have to be exported. Kept narrower than `IProposalAction` so that raw + * action tuples (e.g. actions decoded from a cross-chain message payload) can be exported as well. + */ +export type IExportableAction = Pick; + export interface IImportActionsResult { /** * Whether the import was successful. @@ -63,15 +70,16 @@ export interface IImportActionsResult { class ProposalActionsImportExportUtils { /** - * Exports actions to a JSON-serializable format. + * Exports actions to a JSON-serializable format. Values are exported as strings: a wei amount does not survive a + * JS number (anything above 2^53 wei, i.e. ~0.009 ETH, is silently rounded, and large amounts serialize to + * exponential notation), and the import path accepts a decimal string already. */ - exportActionsToJSON = (actions: IProposalAction[]): IExportedAction[] => + exportActionsToJSON = (actions: IExportableAction[]): IExportedAction[] => actions.map((action) => ({ to: action.to, - value: - typeof action.value === 'bigint' - ? Number(action.value) - : Number(action.value || 0), + // Kept verbatim: a string value is already exact, and a bigint (possible at runtime even though the type + // says otherwise) is stringified rather than narrowed to a number. + value: String(action.value || '0'), data: action.data, })); @@ -79,7 +87,7 @@ class ProposalActionsImportExportUtils { * Downloads actions as a JSON file. */ downloadActionsAsJSON = ( - actions: IProposalAction[], + actions: IExportableAction[], filename = 'actions.json', ): void => { const exportedActions = this.exportActionsToJSON(actions); diff --git a/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/crossChainControllerService.api.ts b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/crossChainControllerService.api.ts new file mode 100644 index 0000000000..7f5ac0930c --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/crossChainControllerService.api.ts @@ -0,0 +1,46 @@ +import type { Network } from '@/shared/api/daoService'; +import type { IRequestUrlBodyParams } from '@/shared/api/httpService'; + +export interface IEstimateGasLimitUrlParams { + /** + * Network of the DAO, i.e. the origin chain the message is forwarded from. + */ + network: Network; + /** + * Address of the cross-chain controller on the origin chain. The backend reads its + * `chainToAdapter` config to resolve the lane, so the adapters are never taken from the client. + */ + controllerAddress: string; +} + +export interface IEstimateGasLimitActionItem { + /** + * Address the action calls on the destination chain. + */ + to: string; + /** + * Value the action sends, as a decimal string. + */ + value: string; + /** + * Calldata of the action. + */ + data: string; +} + +export interface IEstimateGasLimitBody { + /** + * Standard chain id the message is forwarded to. + */ + destinationChainId: number; + /** + * Actions the destination executor runs as a single batch. + */ + actions: IEstimateGasLimitActionItem[]; +} + +export interface IEstimateGasLimitParams + extends IRequestUrlBodyParams< + IEstimateGasLimitUrlParams, + IEstimateGasLimitBody + > {} diff --git a/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/crossChainControllerService.ts b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/crossChainControllerService.ts new file mode 100644 index 0000000000..d77e6f1cca --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/crossChainControllerService.ts @@ -0,0 +1,32 @@ +import { AragonBackendService } from '@/shared/api/aragonBackendService'; +import type { IEstimateGasLimitParams } from './crossChainControllerService.api'; +import type { IGasLimitEstimation } from './domain'; + +class CrossChainControllerService extends AragonBackendService { + private urls = { + estimateGasLimit: + '/v2/simulations/:network/cross-chain/:controllerAddress/gas-limit', + }; + + /** + * Simulates the inbound delivery on the destination chain and returns the `_gasLimit` the + * forwarded message needs. + * + * Runs on the backend because the answer cannot be obtained from `eth_estimateGas`: the + * controller wraps the payload in a `try/catch`, so the node's binary search settles on the + * cost of the catch branch and never measures the actions at all. + */ + estimateGasLimit = async ( + params: IEstimateGasLimitParams, + ): Promise => { + const result = await this.request( + this.urls.estimateGasLimit, + params, + { method: 'POST' }, + ); + + return result; + }; +} + +export const crossChainControllerService = new CrossChainControllerService(); diff --git a/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/domain/gasLimitEstimation.ts b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/domain/gasLimitEstimation.ts new file mode 100644 index 0000000000..7ade3aa734 --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/domain/gasLimitEstimation.ts @@ -0,0 +1,42 @@ +export enum GasLimitEstimationStatus { + /** + * The simulated delivery executed the actions on the destination chain. + */ + SUCCESS = 'success', + /** + * The actions were reached but reverted, so no gas figure could be measured. + */ + REVERTED = 'reverted', +} + +export interface IGasLimitEstimation { + /** + * Outcome of the simulation. Only `success` carries a usable `requiredGas`. + */ + status: GasLimitEstimationStatus; + /** + * Gas the delivery consumed in simulation, including the reserve the controller withholds from + * the payload, as a decimal string. Set only when `status` is `success`. + * + * This is a measurement, not a recommendation: it carries no safety margin and is not checked + * against the lane's per-message gas cap. Applying a margin and deciding whether it fits are + * the client's, via `crossChainControllerGasUtils`. + */ + requiredGas?: string; + /** + * Decoded revert reason of the failing action. Set when `status` is `reverted`. + */ + revertReason?: string; + /** + * Zero-based index of the action that reverted, when the backend can attribute it. + */ + revertedActionIndex?: number; + /** + * URL of the saved Tenderly simulation, for the user to inspect the trace. + */ + simulationUrl?: string; + /** + * Timestamp of the simulation, in milliseconds. + */ + runAt: number; +} diff --git a/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/domain/index.ts b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/domain/index.ts new file mode 100644 index 0000000000..52a8bb3d3e --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/domain/index.ts @@ -0,0 +1 @@ +export * from './gasLimitEstimation'; diff --git a/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/index.ts b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/index.ts new file mode 100644 index 0000000000..08c2a47e82 --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/index.ts @@ -0,0 +1,4 @@ +export { crossChainControllerService } from './crossChainControllerService'; +export type * from './crossChainControllerService.api'; +export * from './domain'; +export * from './mutations'; diff --git a/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/mutations/index.ts b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/mutations/index.ts new file mode 100644 index 0000000000..62703b2ad3 --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/mutations/index.ts @@ -0,0 +1 @@ +export * from './useEstimateGasLimit'; diff --git a/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/mutations/useEstimateGasLimit/index.ts b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/mutations/useEstimateGasLimit/index.ts new file mode 100644 index 0000000000..be36f8f48a --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/mutations/useEstimateGasLimit/index.ts @@ -0,0 +1 @@ +export { useEstimateGasLimit } from './useEstimateGasLimit'; diff --git a/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/mutations/useEstimateGasLimit/useEstimateGasLimit.ts b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/mutations/useEstimateGasLimit/useEstimateGasLimit.ts new file mode 100644 index 0000000000..33ffe6b866 --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/api/crossChainControllerService/mutations/useEstimateGasLimit/useEstimateGasLimit.ts @@ -0,0 +1,17 @@ +import { type MutationOptions, useMutation } from '@tanstack/react-query'; +import { crossChainControllerService } from '../../crossChainControllerService'; +import type { IEstimateGasLimitParams } from '../../crossChainControllerService.api'; +import type { IGasLimitEstimation } from '../../domain'; + +export const useEstimateGasLimit = ( + options?: MutationOptions< + IGasLimitEstimation, + unknown, + IEstimateGasLimitParams + >, +) => + useMutation({ + mutationFn: (params) => + crossChainControllerService.estimateGasLimit(params), + ...options, + }); diff --git a/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.test.tsx b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.test.tsx new file mode 100644 index 0000000000..810fd656ba --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.test.tsx @@ -0,0 +1,363 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; +import { FormProvider, type UseFormReturn, useForm } from 'react-hook-form'; +import { generateToken } from '@/modules/finance/testUtils'; +import * as createProposalForm from '@/modules/governance/components/createProposalForm'; +import { Network, PluginInterfaceType } from '@/shared/api/daoService'; +import * as dialogProvider from '@/shared/components/dialogProvider'; +import * as useDaoChainHook from '@/shared/hooks/useDaoChain'; +import { + generateDaoPlugin, + generateDialogContext, + ReactQueryWrapper, +} from '@/shared/testUtils'; +import { + crossChainControllerService, + GasLimitEstimationStatus, + type IGasLimitEstimation, +} from '../../../api/crossChainControllerService'; +import type { ICrossChainControllerPluginSettings } from '../../../types'; +import { + CrossChainControllerForwardMessageAction, + type ICrossChainControllerForwardMessageActionProps, +} from './crossChainControllerForwardMessageAction'; + +describe(' component', () => { + const useDaoChainSpy = jest.spyOn(useDaoChainHook, 'useDaoChain'); + const useDialogContextSpy = jest.spyOn(dialogProvider, 'useDialogContext'); + const useCreateProposalFormContextSpy = jest.spyOn( + createProposalForm, + 'useCreateProposalFormContext', + ); + const estimateGasLimitSpy = jest.spyOn( + crossChainControllerService, + 'estimateGasLimit', + ); + + const controllerAddress = '0x1111111111111111111111111111111111111111'; + const feeTokenAddress = '0x8888888888888888888888888888888888888888'; + const destinationChainId = 8453; + const otherDestinationChainId = 42_161; + + const nestedAction = { + to: '0x4444444444444444444444444444444444444444', + value: '0', + data: '0xdeadbeef', + }; + + // The component drives every value through the form, so tests need a handle on it to simulate + // the nested-actions dialog writing a new action list. + let form: UseFormReturn | undefined; + + const FormHarness: React.FC<{ + children?: ReactNode; + defaultValues: Record; + }> = (props) => { + const { children, defaultValues } = props; + const methods = useForm({ defaultValues }); + form = methods; + + return {children}; + }; + + const generateEstimation = ( + estimation?: Partial, + ): IGasLimitEstimation => ({ + status: GasLimitEstimationStatus.SUCCESS, + requiredGas: '228100', + runAt: 0, + ...estimation, + }); + + beforeEach(() => { + useDaoChainSpy.mockReturnValue({ + chainId: 1, + network: Network.ETHEREUM_MAINNET, + } as unknown as ReturnType); + useDialogContextSpy.mockReturnValue(generateDialogContext()); + useCreateProposalFormContextSpy.mockReturnValue( + {} as ReturnType< + typeof createProposalForm.useCreateProposalFormContext + >, + ); + estimateGasLimitSpy.mockResolvedValue(generateEstimation()); + }); + + afterEach(() => { + form = undefined; + useDaoChainSpy.mockReset(); + useDialogContextSpy.mockReset(); + useCreateProposalFormContextSpy.mockReset(); + estimateGasLimitSpy.mockReset(); + }); + + const createTestComponent = ( + props?: Partial, + formValues?: Record, + ) => { + const meta = generateDaoPlugin({ + address: controllerAddress, + interfaceType: PluginInterfaceType.CROSS_CHAIN_CONTROLLER, + settings: { + pluginAddress: controllerAddress, + crossChain: { + executor: '0x2222222222222222222222222222222222222222', + lanes: [ + { + chainId: destinationChainId, + localAdapter: + '0x3333333333333333333333333333333333333333', + remoteAdapter: + '0x5555555555555555555555555555555555555555', + feeToken: feeTokenAddress, + token: generateToken({ + address: feeTokenAddress, + symbol: 'LINK', + }), + }, + // The backend leaves the token off the lane when the fee token is unknown. + { + chainId: otherDestinationChainId, + localAdapter: + '0x6666666666666666666666666666666666666666', + remoteAdapter: + '0x7777777777777777777777777777777777777777', + }, + ], + }, + } as ICrossChainControllerPluginSettings, + }); + + const completeProps = { + index: 0, + action: { daoId: 'dao-id', meta }, + ...props, + } as unknown as ICrossChainControllerForwardMessageActionProps; + + const defaultValues = { + actions: [ + { + destinationChainId, + nestedActions: [nestedAction], + ...formValues, + }, + ], + }; + + return ( + + + + + + + + ); + }; + + const getGasLimitInput = () => + screen.getByRole('textbox', { + name: /crossChainControllerForwardMessageAction.gas.label/, + }); + + const clickCalculate = () => + userEvent.click( + screen.getByRole('button', { + name: /crossChainControllerForwardMessageAction.gas.calculate/, + }), + ); + + it('leaves the gas limit empty until it is calculated, instead of defaulting it to the minimum', () => { + render(createTestComponent()); + + expect(getGasLimitInput()).toHaveValue(''); + }); + + it('applies the safety margin locally to the gas the backend measured', async () => { + render(createTestComponent()); + + await clickCalculate(); + + // The backend reports 228,100 with no margin; the 30% margin is this client's decision. + await waitFor(() => expect(getGasLimitInput()).toHaveValue('296,530')); + expect(estimateGasLimitSpy).toHaveBeenCalledWith({ + urlParams: { + network: Network.ETHEREUM_MAINNET, + controllerAddress, + }, + body: { destinationChainId, actions: [nestedAction] }, + }); + }); + + it('clamps to the cap and warns when the full margin does not fit under it', async () => { + estimateGasLimitSpy.mockResolvedValue( + generateEstimation({ requiredGas: '2500000' }), + ); + + render(createTestComponent()); + + await clickCalculate(); + + // 2,500,000 x 1.3 = 3,250,000, above the cap, but the requirement itself still fits. + await waitFor(() => + expect(getGasLimitInput()).toHaveValue('3,000,000'), + ); + expect( + screen.getByText( + /crossChainControllerForwardMessageAction.gas.marginReduced/, + ), + ).toBeInTheDocument(); + }); + + it('keeps the gas limit empty and reports the batch cannot be delivered when the requirement alone exceeds the cap', async () => { + // The backend never checks the requirement against the cap, so this is the client's own + // verdict - no choice of margin fixes it. + estimateGasLimitSpy.mockResolvedValue( + generateEstimation({ requiredGas: '3500000' }), + ); + + render(createTestComponent()); + + await clickCalculate(); + + await waitFor(() => + expect( + screen.getByText( + /crossChainControllerForwardMessageAction.gas.exceedsMax/, + ), + ).toBeInTheDocument(), + ); + expect(getGasLimitInput()).toHaveValue(''); + }); + + it('keeps the gas limit empty and reports the reason when the actions revert', async () => { + estimateGasLimitSpy.mockResolvedValue( + generateEstimation({ + status: GasLimitEstimationStatus.REVERTED, + requiredGas: undefined, + revertReason: 'ERC20: insufficient balance', + }), + ); + + render(createTestComponent()); + + await clickCalculate(); + + await waitFor(() => + expect( + screen.getByText( + /crossChainControllerForwardMessageAction.gas.reverted/, + ), + ).toBeInTheDocument(), + ); + expect(getGasLimitInput()).toHaveValue(''); + }); + + it('clears a calculated gas limit when the actions change, so a stale value cannot reach the proposal', async () => { + render(createTestComponent()); + + await clickCalculate(); + await waitFor(() => expect(getGasLimitInput()).toHaveValue('296,530')); + + act(() => + form?.setValue('actions.[0].nestedActions', [ + nestedAction, + { ...nestedAction, data: '0xfeedface' }, + ]), + ); + + await waitFor(() => expect(getGasLimitInput()).toHaveValue('')); + }); + + it('clears a calculated gas limit when the destination chain changes', async () => { + render(createTestComponent()); + + await clickCalculate(); + await waitFor(() => expect(getGasLimitInput()).toHaveValue('296,530')); + + act(() => form?.setValue('actions.[0].destinationChainId', 42_161)); + + await waitFor(() => expect(getGasLimitInput()).toHaveValue('')); + }); + + it('keeps a restored gas limit on mount', () => { + render(createTestComponent(undefined, { gasLimit: '500000' })); + + expect(getGasLimitInput()).toHaveValue('500,000'); + }); + + it('rejects a fractional gas limit instead of failing to encode it', async () => { + // The masked input accepts the radix character, so a manually typed fraction gets this far. + render(createTestComponent(undefined, { gasLimit: '250000.5' })); + + await act(async () => { + await form?.trigger(); + }); + + expect( + screen.getByText( + /crossChainControllerForwardMessageAction.gas.notWholeNumber/, + ), + ).toBeInTheDocument(); + // The action stays encodable, with a zero limit the required rule keeps out of a proposal. + expect( + form?.getValues('actions.[0].inputData.parameters[1].value'), + ).toBe('0'); + }); + + it('clears the nested actions when the destination chain changes, as they target the previous chain', async () => { + render(createTestComponent()); + + await userEvent.click(screen.getByRole('radio', { name: 'Arbitrum' })); + + await waitFor(() => + expect(form?.getValues('actions.[0].nestedActions')).toEqual([]), + ); + expect(form?.getValues('actions.[0].destinationChainId')).toBe( + otherDestinationChainId, + ); + expect( + screen.getByText( + /crossChainControllerForwardMessageAction.actions.emptyHeading/, + ), + ).toBeInTheDocument(); + }); + + it('keeps the nested actions when the already selected destination chain is selected again', async () => { + render(createTestComponent()); + + await userEvent.click(screen.getByRole('radio', { name: 'Base' })); + + expect(form?.getValues('actions.[0].nestedActions')).toEqual([ + nestedAction, + ]); + }); + + it('names the fee token of the selected lane, as indexed by the backend', () => { + render(createTestComponent()); + + expect( + screen.getByText( + /crossChainControllerForwardMessageAction.fee.description.*token=LINK/, + ), + ).toBeInTheDocument(); + }); + + it('falls back to a generic fee token label when the lane has no indexed token', async () => { + render(createTestComponent()); + + await userEvent.click(screen.getByRole('radio', { name: 'Arbitrum' })); + + await waitFor(() => + expect( + screen.getByText( + /crossChainControllerForwardMessageAction.fee.description.*token=app.plugins.crossChainController.crossChainControllerForwardMessageAction.fee.defaultToken/, + ), + ).toBeInTheDocument(), + ); + }); +}); diff --git a/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.tsx b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.tsx index 3898b125cf..cc2140f1d6 100644 --- a/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.tsx +++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.tsx @@ -1,16 +1,430 @@ 'use client'; -import type { IProposalActionComponentProps } from '@aragon/gov-ui-kit'; +import { + AlertCard, + AlertInline, + addressUtils, + Button, + Card, + CardEmptyState, + IconType, + InputContainer, + type IProposalActionComponentProps, + invariant, + Link, + RadioCard, + RadioGroup, +} from '@aragon/gov-ui-kit'; +import { useEffect, useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { encodeAbiParameters, encodeFunctionData, type Hex } from 'viem'; import type { IProposalAction } from '@/modules/governance/api/governanceService'; import type { IProposalActionData } from '@/modules/governance/components/createProposalForm'; -import type { ICrossChainControllerPlugin } from '../../../types'; +import { useCreateProposalFormContext } from '@/modules/governance/components/createProposalForm'; +import { GovernanceDialogId } from '@/modules/governance/constants/governanceDialogId'; +import type { INestedActionsDialogParams } from '@/modules/governance/dialogs/nestedActionsDialog'; +import { useDialogContext } from '@/shared/components/dialogProvider'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +import { useDaoChain } from '@/shared/hooks/useDaoChain'; +import { useFormField } from '@/shared/hooks/useFormField'; +import { networkUtils } from '@/shared/utils/networkUtils'; +import { + forwardMessageAbi, + forwardMessageActionsAbi, +} from '../../../constants/crossChainControllerAbi'; +import { crossChainControllerGas } from '../../../constants/crossChainControllerGas'; +import type { + ICrossChainControllerActionForwardMessage, + ICrossChainControllerPlugin, +} from '../../../types'; +import { CrossChainControllerProposalActionType } from '../../../types'; +import { crossChainControllerGasUtils } from './crossChainControllerGasUtils'; +import { GasLimitInput } from './gasLimitInput'; +import { useCrossChainControllerGasLimit } from './useCrossChainControllerGasLimit'; export interface ICrossChainControllerForwardMessageActionProps extends IProposalActionComponentProps< IProposalActionData > {} -// TODO(APP-1029): implement the destination chain selection and the nested actions composition. export const CrossChainControllerForwardMessageAction: React.FC< ICrossChainControllerForwardMessageActionProps -> = () => null; +> = (props) => { + const { index, action } = props; + const { daoId } = action; + + // The DAO chain is needed to exclude it from the destination chains, so this view only supports + // actions composed in DAO context. + invariant( + daoId != null, + 'CrossChainControllerForwardMessageAction: daoId must be set on the action.', + ); + + const { lanes } = action.meta.settings.crossChain; + + const { t } = useTranslations(); + const { open } = useDialogContext(); + const { setValue } = useFormContext(); + const { chainId: daoChainId, network: daoNetwork } = useDaoChain({ daoId }); + + // The nested actions are part of the proposal, so they are restricted by the process creating it. + const { processPlugin } = useCreateProposalFormContext(); + + const actionFieldName = `actions.[${index.toString()}]`; + useFormField, typeof actionFieldName>( + actionFieldName, + ); + + // Filter out DAO's own chain + const destinationChains = useMemo( + () => + lanes + .filter(({ chainId }) => chainId !== daoChainId) + .map(({ chainId }) => { + const network = networkUtils.getNetworkByChainId(chainId); + const definition = + network != null + ? networkDefinitions[network] + : undefined; + + return { + chainId, + network, + name: definition?.name, + logo: definition?.logo, + }; + }), + [lanes, daoChainId], + ); + + const { + onChange: onDestinationChainChange, + value: destinationChainId, + ...destinationChainField + } = useFormField< + ICrossChainControllerActionForwardMessage, + 'destinationChainId' + >('destinationChainId', { + label: t( + 'app.plugins.crossChainController.crossChainControllerForwardMessageAction.chain.label', + ), + rules: { required: true }, + fieldPrefix: actionFieldName, + }); + + const { + onChange: onNestedActionsChange, + value: nestedActions = [], + alert: nestedActionsAlert, + label: nestedActionsLabel, + } = useFormField< + ICrossChainControllerActionForwardMessage, + 'nestedActions' + >('nestedActions', { + label: t( + 'app.plugins.crossChainController.crossChainControllerForwardMessageAction.actions.label', + ), + rules: { validate: (value) => value != null && value.length > 0 }, + fieldPrefix: actionFieldName, + }); + + const { + onChange: onGasLimitChange, + value: gasLimit, + ...gasLimitField + } = useFormField( + 'gasLimit', + { + label: t( + 'app.plugins.crossChainController.crossChainControllerForwardMessageAction.gas.label', + ), + rules: { + required: true, + min: crossChainControllerGas.minGasLimit, + max: crossChainControllerGas.maxGasLimit, + // The masked input accepts the radix character, so a manually typed fraction has to + // be rejected here - it is neither a valid gas figure nor encodable as a uint256. + validate: (value: string | undefined) => + crossChainControllerGasUtils.parseGasLimit(value) != null || + 'app.plugins.crossChainController.crossChainControllerForwardMessageAction.gas.notWholeNumber', + }, + fieldPrefix: actionFieldName, + }, + ); + + const handleDestinationChainChange = (value: string) => { + const newDestinationChainId = Number(value); + + // Guard against re-selecting the current chain, which must not discard the actions. + if (newDestinationChainId === destinationChainId) { + return; + } + + onDestinationChainChange(newDestinationChainId); + + // The nested actions target contracts on the previous destination chain, so they cannot + // survive a change of destination. The gas limit is cleared by the estimation hook. + onNestedActionsChange([]); + }; + + // The nested actions are executed by the destination chain controller, therefore they are composed + // for the selected destination network instead of the DAO network. + const destinationNetwork = destinationChains.find( + ({ chainId }) => chainId === destinationChainId, + )?.network; + + // The messaging fee is paid by the controller on the DAO chain with the fee token set on the + // local adapter of the selected lane, which the backend indexes onto the lane. + const feeToken = lanes.find( + ({ chainId }) => chainId === destinationChainId, + )?.token; + + const handleOpenActionsDialog = () => { + invariant( + destinationNetwork != null, + 'CrossChainControllerForwardMessageAction: destination network must be set.', + ); + + const params: INestedActionsDialogParams = { + hostDaoId: daoId, + processPluginAddress: processPlugin?.address, + crossChainNetwork: destinationNetwork, + initialActions: nestedActions, + // Prevent showing nested forward actions. + excludeActionTypes: [ + CrossChainControllerProposalActionType.FORWARD_MESSAGE, + ], + onSubmit: onNestedActionsChange, + }; + + open(GovernanceDialogId.NESTED_ACTIONS, { + params, + disableOutsideClick: true, + }); + }; + + const encodedMessage = useMemo( + () => + encodeAbiParameters(forwardMessageActionsAbi, [ + nestedActions.map(({ to, value, data }) => ({ + to: to as Hex, + value: BigInt(value || 0), + data: (data || '0x') as Hex, + })), + ]), + [nestedActions], + ); + + const hasNestedActions = nestedActions.length > 0; + + const { + handleEstimateGasLimit, + isEstimating, + estimationAlert, + simulationUrl, + } = useCrossChainControllerGasLimit({ + daoNetwork, + controllerAddress: action.meta.address, + destinationChainId, + nestedActions, + onGasLimitChange, + }); + + useEffect(() => { + if (destinationChainId == null) { + return; + } + + // Encodes to zero while the limit is unset or not a whole number. The field is required and + // rejects fractions, so a proposal can never be created in that state. + const encodedGasLimit = + crossChainControllerGasUtils.parseGasLimit(gasLimit) ?? BigInt(0); + + const newData = encodeFunctionData({ + abi: [forwardMessageAbi], + functionName: 'forwardMessage', + args: [BigInt(destinationChainId), encodedGasLimit, encodedMessage], + }); + + setValue(`${actionFieldName}.data`, newData); + setValue( + `${actionFieldName}.inputData.parameters[0].value`, + destinationChainId.toString(), + ); + setValue( + `${actionFieldName}.inputData.parameters[1].value`, + encodedGasLimit.toString(), + ); + setValue( + `${actionFieldName}.inputData.parameters[2].value`, + encodedMessage, + ); + }, [ + actionFieldName, + destinationChainId, + encodedMessage, + gasLimit, + setValue, + ]); + + return ( +
+ {destinationChains.length > 0 ? ( + + {destinationChains.map(({ chainId, name, logo }) => ( + + ))} + + ) : ( + + )} + + + {hasNestedActions ? ( + +

+ {t( + nestedActions.length === 1 + ? 'app.plugins.crossChainController.crossChainControllerForwardMessageAction.actions.selected' + : 'app.plugins.crossChainController.crossChainControllerForwardMessageAction.actions.selectedPlural', + { count: nestedActions.length }, + )} +

+ +
+ ) : ( + + )} +
+ + {hasNestedActions && ( +
+ + + {(estimationAlert != null || simulationUrl != null) && ( +
+ {estimationAlert != null && ( + + )} + {simulationUrl != null && ( + + {t( + 'app.plugins.crossChainController.crossChainControllerForwardMessageAction.gas.viewSimulation', + )} + + )} +
+ )} +
+ )} + + + {t( + 'app.plugins.crossChainController.crossChainControllerForwardMessageAction.fee.description', + { + address: addressUtils.truncateAddress( + action.meta.address, + ), + token: + feeToken?.symbol ?? + t( + 'app.plugins.crossChainController.crossChainControllerForwardMessageAction.fee.defaultToken', + ), + }, + )} + +
+ ); +}; diff --git a/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerGasUtils.test.ts b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerGasUtils.test.ts new file mode 100644 index 0000000000..4459302eff --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerGasUtils.test.ts @@ -0,0 +1,122 @@ +import { crossChainControllerGas } from '../../../constants/crossChainControllerGas'; +import { crossChainControllerGasUtils } from './crossChainControllerGasUtils'; + +describe('crossChainControllerGas utils', () => { + describe('parseGasLimit', () => { + it('parses a whole number', () => { + expect( + crossChainControllerGasUtils.parseGasLimit('250000'), + ).toEqual(BigInt(250_000)); + }); + + it.each([ + '250000.5', + '250000.', + '2.5e5', + '-250000', + 'abc', + '', + ' ', + ])('rejects %p, which is not a whole number of gas units', (value) => { + expect( + crossChainControllerGasUtils.parseGasLimit(value), + ).toBeUndefined(); + }); + + it('rejects an unset value', () => { + expect( + crossChainControllerGasUtils.parseGasLimit(), + ).toBeUndefined(); + }); + }); + + describe('applyBuffer', () => { + it('adds the given margin', () => { + expect( + crossChainControllerGasUtils.applyBuffer(BigInt(200_000), 50), + ).toEqual(BigInt(300_000)); + }); + + it('returns the input unchanged for a zero margin', () => { + expect( + crossChainControllerGasUtils.applyBuffer(BigInt(200_000), 0), + ).toEqual(BigInt(200_000)); + }); + + it('rounds up so integer division never erodes the margin', () => { + // 7 * 1.5 = 10.5, which must not truncate to 10. + expect( + crossChainControllerGasUtils.applyBuffer(BigInt(7), 50), + ).toEqual(BigInt(11)); + }); + }); + + describe('resolveGasLimit', () => { + const { bufferPercent, minGasLimit, maxGasLimit } = + crossChainControllerGas; + + it('applies the configured margin to the measured requirement', () => { + const requiredGas = BigInt(228_100); + + const result = crossChainControllerGasUtils.resolveGasLimit({ + requiredGas, + }); + + expect(result).toEqual({ + gasLimit: crossChainControllerGasUtils.applyBuffer( + requiredGas, + bufferPercent, + ), + isMarginReduced: false, + exceedsMaxGasLimit: false, + }); + }); + + it('raises a very small requirement to the floor', () => { + const result = crossChainControllerGasUtils.resolveGasLimit({ + requiredGas: BigInt(1000), + }); + + expect(result).toEqual({ + gasLimit: BigInt(minGasLimit), + isMarginReduced: false, + exceedsMaxGasLimit: false, + }); + }); + + it('clamps to the cap and flags the reduced margin when the full margin does not fit', () => { + // Fits under the cap on its own, but not once the margin is added. + const result = crossChainControllerGasUtils.resolveGasLimit({ + requiredGas: BigInt(2_500_000), + }); + + expect(result).toEqual({ + gasLimit: BigInt(maxGasLimit), + isMarginReduced: true, + exceedsMaxGasLimit: false, + }); + }); + + it('still covers the measured requirement when clamped', () => { + const requiredGas = BigInt(2_500_000); + + const { gasLimit } = crossChainControllerGasUtils.resolveGasLimit({ + requiredGas, + }); + + expect(gasLimit).toBeGreaterThanOrEqual(requiredGas); + }); + + it('flags an unfixable requirement when it exceeds the cap on its own, before any margin', () => { + const result = crossChainControllerGasUtils.resolveGasLimit({ + requiredGas: BigInt(3_500_000), + }); + + expect(result).toEqual({ + gasLimit: BigInt(maxGasLimit), + isMarginReduced: true, + exceedsMaxGasLimit: true, + }); + }); + }); +}); diff --git a/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerGasUtils.ts b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerGasUtils.ts new file mode 100644 index 0000000000..928c309bd5 --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerGasUtils.ts @@ -0,0 +1,101 @@ +import { crossChainControllerGas } from '../../../constants/crossChainControllerGas'; + +export interface IResolveGasLimitParams { + /** + * Gas the backend measured the delivery to consume, including the controller's reserve. Carries + * no safety margin and is not checked against the lane cap - the backend deliberately never + * reads it, so the client owns that check entirely. + */ + requiredGas: bigint; +} + +export interface IResolveGasLimitResult { + /** + * Gas limit to submit with the action. Not a usable value when `exceedsMaxGasLimit` is true. + */ + gasLimit: bigint; + /** + * Whether the full margin did not fit under the cap and the limit was clamped to it. The limit + * still covers the measured requirement, but with less headroom than intended. + */ + isMarginReduced: boolean; + /** + * Whether the measured requirement itself, before any margin, is already above the cap. No + * choice of margin fixes this - the batch has to be split across several forward actions. + */ + exceedsMaxGasLimit: boolean; +} + +// Gas is counted in whole units and the limit is ABI-encoded as a uint256, but the masked input +// accepts the locale radix character, so a fractional value can reach the field. +const wholeNumberRegex = /^\d+$/; + +class CrossChainControllerGasUtils { + /** + * Parses a raw gas-limit field value into the number of gas units to encode. + * @param value - Raw value of the gas-limit field. + * @returns The gas limit, or undefined when the value is empty or not a whole number. + */ + parseGasLimit = (value?: string): bigint | undefined => + value != null && wholeNumberRegex.test(value) + ? BigInt(value) + : undefined; + + /** + * Adds a safety margin to a gas figure, rounding up. + * @param gas - The gas to add the margin to. + * @param bufferPercent - The margin to add, in percent. + * @returns The gas including the margin. + */ + applyBuffer = (gas: bigint, bufferPercent: number): bigint => { + const hundred = BigInt(100); + const scaled = gas * BigInt(100 + bufferPercent); + + // Round up so the margin is never eroded by integer division. + return (scaled + hundred - BigInt(1)) / hundred; + }; + + /** + * Turns the backend's measurement into the limit to submit, applying the safety margin, the + * floor and the cap. + * @param params - The measurement. + * @returns The gas limit to submit, whether its margin was cut short, and whether the + * requirement alone already exceeds the cap (in which case `gasLimit` is not usable). + */ + resolveGasLimit = ( + params: IResolveGasLimitParams, + ): IResolveGasLimitResult => { + const { requiredGas } = params; + const { bufferPercent, minGasLimit, maxGasLimit } = + crossChainControllerGas; + const cap = BigInt(maxGasLimit); + + if (requiredGas > cap) { + return { + gasLimit: cap, + isMarginReduced: true, + exceedsMaxGasLimit: true, + }; + } + + const buffered = this.applyBuffer(requiredGas, bufferPercent); + const withFloor = + buffered < BigInt(minGasLimit) ? BigInt(minGasLimit) : buffered; + + if (withFloor > cap) { + return { + gasLimit: cap, + isMarginReduced: true, + exceedsMaxGasLimit: false, + }; + } + + return { + gasLimit: withFloor, + isMarginReduced: false, + exceedsMaxGasLimit: false, + }; + }; +} + +export const crossChainControllerGasUtils = new CrossChainControllerGasUtils(); diff --git a/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/gasLimitInput.tsx b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/gasLimitInput.tsx new file mode 100644 index 0000000000..f3adfd9f7f --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/gasLimitInput.tsx @@ -0,0 +1,150 @@ +import { + Button, + IconType, + type IInputComponentProps, + InputContainer, + mergeRefs, + useInputProps, + useNumberMask, +} from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import type { Ref } from 'react'; + +export interface IGasLimitInputProps + extends Omit< + IInputComponentProps, + 'onChange' | 'step' | 'min' | 'max' | 'maxLength' + > { + /** + * The minimum value that the gas-limit input accepts. + */ + min: number; + /** + * The maximum value that the gas-limit input accepts. + */ + max: number; + /** + * Granularity of the increment/decrement controls. + */ + step: number; + /** + * Callback called when the gas-limit value changes. + */ + onChange?: (value: string) => void; + /** + * Label of the calculate button. + */ + calculateLabel: string; + /** + * Whether the calculate button is disabled. + */ + calculateDisabled?: boolean; + /** + * Whether the gas limit is currently being calculated. + */ + isCalculating?: boolean; + /** + * Callback called when the calculate button is clicked. + */ + onCalculate: () => void; + /** + * Ref forwarded to the underlying input element. + */ + ref?: Ref; +} + +// TODO: update number input in ui-kit to support additional button +export const GasLimitInput: React.FC = (props) => { + const { + min, + max, + step, + onChange, + calculateLabel, + calculateDisabled, + isCalculating, + onCalculate, + ref: forwardedRef, + ...otherProps + } = props; + + const { containerProps, inputProps } = useInputProps(otherProps); + const { className, disabled, ...otherContainerProps } = containerProps; + const { className: inputClassName, value, ...otherInputProps } = inputProps; + + const { ref, unmaskedValue, setUnmaskedValue } = useNumberMask({ + min, + max, + value: value as string | undefined, + onChange, + }); + + // Mirrors gov-ui-kit's own InputNumber stepper logic so both controls and the calculate + // action can live inside the same InputContainer box. + const adjustValue = (direction: 1 | -1) => { + const current = Number(unmaskedValue); + const nextMultiple = + (direction > 0 + ? Math.floor(current / step) + : Math.ceil(current / step)) + direction; + const nextValue = Math.min( + max, + Math.max(min, nextMultiple * step), + ).toString(); + + setUnmaskedValue(nextValue); + onChange?.(nextValue); + }; + + return ( + + {!disabled && ( + + )} + + ); +}; diff --git a/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/useCrossChainControllerGasLimit.test.ts b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/useCrossChainControllerGasLimit.test.ts new file mode 100644 index 0000000000..5affd3b165 --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/useCrossChainControllerGasLimit.test.ts @@ -0,0 +1,258 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { Network } from '@/shared/api/daoService'; +import { ReactQueryWrapper } from '@/shared/testUtils'; +import { + crossChainControllerService, + GasLimitEstimationStatus, + type IGasLimitEstimation, +} from '../../../api/crossChainControllerService'; +import type { + IGasLimitEstimationAction, + IUseCrossChainControllerGasLimitParams, +} from './useCrossChainControllerGasLimit'; +import { useCrossChainControllerGasLimit } from './useCrossChainControllerGasLimit'; + +describe('useCrossChainControllerGasLimit hook', () => { + const estimateGasLimitSpy = jest.spyOn( + crossChainControllerService, + 'estimateGasLimit', + ); + + const controllerAddress = '0x1111111111111111111111111111111111111111'; + const destinationChainId = 8453; + const nestedAction: IGasLimitEstimationAction = { + to: '0x4444444444444444444444444444444444444444', + value: '0', + data: '0xdeadbeef', + }; + + const generateEstimation = ( + estimation?: Partial, + ): IGasLimitEstimation => ({ + status: GasLimitEstimationStatus.SUCCESS, + requiredGas: '228100', + runAt: 0, + ...estimation, + }); + + beforeEach(() => { + estimateGasLimitSpy.mockResolvedValue(generateEstimation()); + }); + + afterEach(() => { + estimateGasLimitSpy.mockReset(); + }); + + const renderGasLimitHook = ( + paramsOverrides?: Partial, + ) => { + const onGasLimitChange = jest.fn(); + const { result, rerender } = renderHook( + (props?: Partial) => + useCrossChainControllerGasLimit({ + daoNetwork: Network.ETHEREUM_MAINNET, + controllerAddress, + destinationChainId, + nestedActions: [nestedAction], + onGasLimitChange, + ...paramsOverrides, + ...props, + }), + { wrapper: ReactQueryWrapper }, + ); + + return { result, rerender, onGasLimitChange }; + }; + + it('starts with no alert and no simulation url', () => { + const { result } = renderGasLimitHook(); + + expect(result.current.estimationAlert).toBeUndefined(); + expect(result.current.simulationUrl).toBeUndefined(); + expect(result.current.isEstimating).toBe(false); + }); + + it('estimates the gas limit and applies the client-side margin', async () => { + const { result, onGasLimitChange } = renderGasLimitHook(); + + act(() => result.current.handleEstimateGasLimit()); + + await waitFor(() => + expect(estimateGasLimitSpy).toHaveBeenCalledWith({ + urlParams: { + network: Network.ETHEREUM_MAINNET, + controllerAddress, + }, + body: { destinationChainId, actions: [nestedAction] }, + }), + ); + // The backend reports 228,100 with no margin; the 30% margin is this client's decision. + await waitFor(() => + expect(onGasLimitChange).toHaveBeenCalledWith('296530'), + ); + }); + + it('does not resolve a gas limit when the requirement alone exceeds the cap', async () => { + estimateGasLimitSpy.mockResolvedValue( + generateEstimation({ requiredGas: '3500000' }), + ); + + const { result, onGasLimitChange } = renderGasLimitHook(); + + act(() => result.current.handleEstimateGasLimit()); + + await waitFor(() => + expect(result.current.estimationAlert?.message).toEqual( + expect.stringContaining('exceedsMax'), + ), + ); + // Only the clear that precedes every calculation, never a resolved limit. + expect(onGasLimitChange).toHaveBeenCalledTimes(1); + expect(onGasLimitChange).toHaveBeenCalledWith(undefined); + }); + + it('clears the previous gas limit before recalculating, so an outcome with no usable limit leaves nothing submittable', async () => { + const { result, onGasLimitChange } = renderGasLimitHook(); + + act(() => result.current.handleEstimateGasLimit()); + await waitFor(() => + expect(onGasLimitChange).toHaveBeenCalledWith('296530'), + ); + + estimateGasLimitSpy.mockResolvedValue( + generateEstimation({ + status: GasLimitEstimationStatus.REVERTED, + requiredGas: undefined, + }), + ); + onGasLimitChange.mockClear(); + + act(() => result.current.handleEstimateGasLimit()); + + await waitFor(() => + expect(result.current.estimationAlert?.message).toEqual( + expect.stringContaining('reverted'), + ), + ); + expect(onGasLimitChange).toHaveBeenCalledTimes(1); + expect(onGasLimitChange).toHaveBeenCalledWith(undefined); + }); + + it('reports the revert reason when the actions fail in simulation', async () => { + estimateGasLimitSpy.mockResolvedValue( + generateEstimation({ + status: GasLimitEstimationStatus.REVERTED, + requiredGas: undefined, + revertReason: 'ERC20: insufficient balance', + }), + ); + + const { result } = renderGasLimitHook(); + + act(() => result.current.handleEstimateGasLimit()); + + await waitFor(() => + expect(result.current.estimationAlert?.message).toEqual( + expect.stringContaining('reverted'), + ), + ); + }); + + it('exposes the simulation url from the last estimation', async () => { + estimateGasLimitSpy.mockResolvedValue( + generateEstimation({ simulationUrl: 'https://tenderly.co/x' }), + ); + + const { result } = renderGasLimitHook(); + + act(() => result.current.handleEstimateGasLimit()); + + await waitFor(() => + expect(result.current.simulationUrl).toEqual( + 'https://tenderly.co/x', + ), + ); + }); + + it('clears a resolved gas limit when the destination chain changes', async () => { + const { result, rerender, onGasLimitChange } = renderGasLimitHook(); + + act(() => result.current.handleEstimateGasLimit()); + await waitFor(() => + expect(onGasLimitChange).toHaveBeenCalledWith('296530'), + ); + + onGasLimitChange.mockClear(); + rerender({ destinationChainId: 42_161 }); + + expect(onGasLimitChange).toHaveBeenCalledWith(undefined); + }); + + it('clears a resolved gas limit when the nested actions change', async () => { + const { result, rerender, onGasLimitChange } = renderGasLimitHook(); + + act(() => result.current.handleEstimateGasLimit()); + await waitFor(() => + expect(onGasLimitChange).toHaveBeenCalledWith('296530'), + ); + + onGasLimitChange.mockClear(); + rerender({ nestedActions: [{ ...nestedAction, data: '0xfeedface' }] }); + + expect(onGasLimitChange).toHaveBeenCalledWith(undefined); + }); + + it('ignores an in-flight estimation result once the destination chain changes before it resolves', async () => { + let resolveEstimation: (estimation: IGasLimitEstimation) => void = () => + undefined; + estimateGasLimitSpy.mockReturnValue( + new Promise((resolve) => { + resolveEstimation = resolve; + }), + ); + + const { result, rerender, onGasLimitChange } = renderGasLimitHook(); + + act(() => result.current.handleEstimateGasLimit()); + onGasLimitChange.mockClear(); + + rerender({ destinationChainId: 42_161 }); + expect(onGasLimitChange).toHaveBeenCalledWith(undefined); + onGasLimitChange.mockClear(); + + await act(async () => resolveEstimation(generateEstimation())); + + expect(onGasLimitChange).not.toHaveBeenCalled(); + }); + + it('ignores an in-flight estimation result once the nested actions change before it resolves', async () => { + let resolveEstimation: (estimation: IGasLimitEstimation) => void = () => + undefined; + estimateGasLimitSpy.mockReturnValue( + new Promise((resolve) => { + resolveEstimation = resolve; + }), + ); + + const { result, rerender, onGasLimitChange } = renderGasLimitHook(); + + act(() => result.current.handleEstimateGasLimit()); + onGasLimitChange.mockClear(); + + rerender({ nestedActions: [{ ...nestedAction, data: '0xfeedface' }] }); + expect(onGasLimitChange).toHaveBeenCalledWith(undefined); + onGasLimitChange.mockClear(); + + await act(async () => resolveEstimation(generateEstimation())); + + expect(onGasLimitChange).not.toHaveBeenCalled(); + }); + + it('throws when estimating without a resolved network or destination', () => { + const { result } = renderGasLimitHook({ + destinationChainId: undefined, + }); + + expect(() => result.current.handleEstimateGasLimit()).toThrow(); + }); +}); diff --git a/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/useCrossChainControllerGasLimit.ts b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/useCrossChainControllerGasLimit.ts new file mode 100644 index 0000000000..98a344a956 --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/useCrossChainControllerGasLimit.ts @@ -0,0 +1,277 @@ +'use client'; + +import { formatterUtils, invariant, NumberFormat } from '@aragon/gov-ui-kit'; +import { useEffect, useRef } from 'react'; +import type { Network } from '@/shared/api/daoService'; +import { + type TranslationFunction, + useTranslations, +} from '@/shared/components/translationsProvider'; +import { + GasLimitEstimationStatus, + type IGasLimitEstimation, + useEstimateGasLimit, +} from '../../../api/crossChainControllerService'; +import { crossChainControllerGas } from '../../../constants/crossChainControllerGas'; +import { crossChainControllerGasUtils } from './crossChainControllerGasUtils'; + +/** + * Minimal shape of a nested action needed to estimate its gas cost on the destination chain. + */ +export interface IGasLimitEstimationAction { + to: string; + value: string; + data: string; +} + +export interface IUseCrossChainControllerGasLimitParams { + /** + * Network of the DAO, i.e. the origin chain the message is forwarded from. + */ + daoNetwork?: Network; + /** + * Address of the cross-chain controller the gas limit is estimated for. + */ + controllerAddress: string; + /** + * Standard chain id of the chain the message is forwarded to. + */ + destinationChainId?: number; + /** + * Actions the destination executor runs as a single batch. + */ + nestedActions: IGasLimitEstimationAction[]; + /** + * Callback called to update the gas-limit field value. + */ + onGasLimitChange: (value?: string) => void; +} + +export interface IGasLimitEstimationAlert { + message: string; + variant: 'critical' | 'warning' | 'success'; +} + +export interface IUseCrossChainControllerGasLimitResult { + /** + * Triggers a new gas-limit simulation for the current destination and actions. + */ + handleEstimateGasLimit: () => void; + /** + * Whether a simulation is currently in flight. + */ + isEstimating: boolean; + /** + * Reports how the last calculation went, ready to render as an alert. Undefined before the + * first calculation. + */ + estimationAlert?: IGasLimitEstimationAlert; + /** + * URL of the saved Tenderly simulation, when the last calculation ran one. + */ + simulationUrl?: string; +} + +const formatGas = (gas?: string) => + formatterUtils.formatNumber(gas ?? '0', { + format: NumberFormat.GENERIC_SHORT, + }); + +interface IBuildEstimationAlertParams { + t: TranslationFunction; + estimation: IGasLimitEstimation | undefined; + isEstimationError: boolean; +} + +/** + * Turns the last simulation outcome into a user-facing alert. The backend only measures - the + * verdicts about margin and cap are the client's, applied here via `crossChainControllerGasUtils`. + */ +const buildEstimationAlert = ( + params: IBuildEstimationAlertParams, +): IGasLimitEstimationAlert | undefined => { + const { t, estimation, isEstimationError } = params; + const translationPrefix = + 'app.plugins.crossChainController.crossChainControllerForwardMessageAction.gas'; + + if (isEstimationError) { + return { + message: t(`${translationPrefix}.error`), + variant: 'critical', + }; + } + + if (estimation == null) { + return undefined; + } + + if (estimation.status === GasLimitEstimationStatus.REVERTED) { + return { + message: t(`${translationPrefix}.reverted`, { + reason: + estimation.revertReason ?? + t(`${translationPrefix}.unknownReason`), + }), + variant: 'critical', + }; + } + + if (estimation.requiredGas == null) { + return undefined; + } + + const { isMarginReduced, exceedsMaxGasLimit } = + crossChainControllerGasUtils.resolveGasLimit({ + requiredGas: BigInt(estimation.requiredGas), + }); + + // The backend never checks the requirement against the cap, so this is the client's own + // verdict: no choice of margin makes this deliverable, the batch has to be split. + if (exceedsMaxGasLimit) { + return { + message: t(`${translationPrefix}.exceedsMax`, { + maxGasLimit: formatGas( + crossChainControllerGas.maxGasLimit.toString(), + ), + }), + variant: 'critical', + }; + } + + // The limit still covers the measured requirement, it just carries less headroom than the + // configured margin, which is worth saying out loud. + if (isMarginReduced) { + return { + message: t(`${translationPrefix}.marginReduced`, { + requiredGas: formatGas(estimation.requiredGas), + }), + variant: 'warning', + }; + } + + return { + message: t(`${translationPrefix}.simulated`, { + requiredGas: formatGas(estimation.requiredGas), + bufferPercent: crossChainControllerGas.bufferPercent, + }), + variant: 'success', + }; +}; + +/** + * Estimates the `_gasLimit` a `forwardMessage` action needs, applying the client-side safety + * margin, floor and cap on top of the backend's bare measurement (see `crossChainControllerGas`). + * + * Also clears a previously calculated limit whenever the destination or the actions change: a + * limit measured for a different payload is worse than none - it looks authoritative and is + * silently wrong. The same guard covers the in-flight case: if the subject changes before a + * running estimation resolves, its `onSuccess` is ignored instead of writing a stale gas limit. + */ +export const useCrossChainControllerGasLimit = ( + params: IUseCrossChainControllerGasLimitParams, +): IUseCrossChainControllerGasLimitResult => { + const { + daoNetwork, + controllerAddress, + destinationChainId, + nestedActions, + onGasLimitChange, + } = params; + + const { t } = useTranslations(); + + const { + mutate: estimateGasLimit, + data: estimation, + isPending: isEstimating, + isError: isEstimationError, + reset: resetEstimation, + } = useEstimateGasLimit(); + + // The gas limit is measured against a specific payload on a specific chain, so it is only + // valid for the pair it was calculated from. + const estimationSubject = `${destinationChainId?.toString() ?? ''}:${JSON.stringify(nestedActions)}`; + const lastEstimationSubject = useRef(estimationSubject); + + useEffect(() => { + if (lastEstimationSubject.current === estimationSubject) { + return; + } + + lastEstimationSubject.current = estimationSubject; + onGasLimitChange(undefined); + resetEstimation(); + }, [estimationSubject, onGasLimitChange, resetEstimation]); + + const handleEstimateGasLimit = () => { + invariant( + daoNetwork != null && destinationChainId != null, + 'useCrossChainControllerGasLimit: network and destination must be set to estimate gas.', + ); + + // Every calculation starts from an empty field, so an outcome that yields no usable limit - + // an error, a revert, or a requirement above the cap - cannot leave the previous value + // behind, looking authoritative and still submittable. + onGasLimitChange(undefined); + + // Snapshot the subject being estimated: if the destination or actions change before this + // request resolves, the effect above updates the ref to the new subject, and the mismatch + // tells onSuccess its result no longer describes the current payload. + const requestedSubject = estimationSubject; + + estimateGasLimit( + { + urlParams: { network: daoNetwork, controllerAddress }, + body: { + destinationChainId, + actions: nestedActions.map(({ to, value, data }) => ({ + to, + value: value || '0', + data: data || '0x', + })), + }, + }, + { + // The backend only measures. The safety margin, floor and cap on top of that + // measurement are a product decision and are applied here. + onSuccess: (result) => { + if (requestedSubject !== lastEstimationSubject.current) { + return; + } + + if ( + result.status !== GasLimitEstimationStatus.SUCCESS || + result.requiredGas == null + ) { + return; + } + + const { gasLimit: resolvedGasLimit, exceedsMaxGasLimit } = + crossChainControllerGasUtils.resolveGasLimit({ + requiredGas: BigInt(result.requiredGas), + }); + + // No choice of margin makes this deliverable; leaving the field empty keeps + // the required rule from letting a wrong-but-plausible value reach the + // proposal. + if (exceedsMaxGasLimit) { + return; + } + + onGasLimitChange(resolvedGasLimit.toString()); + }, + }, + ); + }; + + return { + handleEstimateGasLimit, + isEstimating, + estimationAlert: buildEstimationAlert({ + t, + estimation, + isEstimationError, + }), + simulationUrl: estimation?.simulationUrl, + }; +}; diff --git a/apps/app/src/plugins/crossChainControllerPlugin/constants/crossChainControllerAbi.ts b/apps/app/src/plugins/crossChainControllerPlugin/constants/crossChainControllerAbi.ts new file mode 100644 index 0000000000..962873cf29 --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/constants/crossChainControllerAbi.ts @@ -0,0 +1,35 @@ +/** + * ABI of the `forwardMessage` entry point of the cross-chain controller, used to encode the action calldata. + */ +export const forwardMessageAbi = { + type: 'function', + inputs: [ + { + name: '_destinationChainId', + internalType: 'uint256', + type: 'uint256', + }, + { name: '_gasLimit', internalType: 'uint256', type: 'uint256' }, + { name: '_message', internalType: 'bytes', type: 'bytes' }, + ], + name: 'forwardMessage', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'nonpayable', +} as const; + +/** + * ABI parameters of the `_message` payload of `forwardMessage`. The payload is the ABI encoding of the OSx `Action[]` + * the destination controller hands to its executor, therefore it is used both to encode the nested actions on the + * create view and to decode them back on the details view. + */ +export const forwardMessageActionsAbi = [ + { + name: 'actions', + type: 'tuple[]', + components: [ + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + }, +] as const; diff --git a/apps/app/src/plugins/crossChainControllerPlugin/constants/crossChainControllerGas.ts b/apps/app/src/plugins/crossChainControllerPlugin/constants/crossChainControllerGas.ts new file mode 100644 index 0000000000..427947c2ce --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/constants/crossChainControllerGas.ts @@ -0,0 +1,40 @@ +/** + * Client-side policy for the `_gasLimit` carried by `forwardMessage`. + * + */ +export const crossChainControllerGas = { + /** + * Safety margin added to the measured requirement, in percent. + * + * Chainlink suggests around 10% for an ordinary receiver. This is deliberately far higher: + * + * - The payload is arbitrary, user-composed actions rather than a fixed receiver. + * - The limit is frozen into the proposal calldata at creation time and only spent when the + * proposal executes, which can be weeks later, against destination state that has moved. + * - The EVM withholds 1/64 of the remaining gas at every nested call, and the delivery is five + * frames deep (`ccipReceive` -> `receiveMessage` -> `executeActions` -> `Executor.execute` -> + * the action itself). + * + * Above all, the failure modes are asymmetric. A limit that is *slightly* short records the + * message as delivered without running its actions, recoverable only through a permissioned + * retry on the destination chain. A limit that is far too short simply reverts the delivery and + * stays re-executable by anyone. Overpaying is the cheap mistake; do not lower this without + * measurements. + */ + bufferPercent: 30, + /** + * Floor applied to the final limit, equal to the CCIP default. Anything lower cannot cover the + * adapter and controller preamble, let alone any action. + */ + minGasLimit: 200_000, + /** + * Ceiling applied to the submitted gas limit. + * + * The backend deliberately never reads or reports the lane's real `maxPerMsgGasLimit` - + * checking the cap is left entirely to the client. CCIP rejects a message above the lane cap + * when `ccipSend` runs, which on the origin chain means the proposal passes and then fails to + * execute. The cap is per-lane source-side config; 3,000,000 is the common value and is used + * here as a conservative, hardcoded stand-in. + */ + maxGasLimit: 3_000_000, +} as const; diff --git a/apps/app/src/plugins/crossChainControllerPlugin/types/crossChainControllerActionForwardMessage.ts b/apps/app/src/plugins/crossChainControllerPlugin/types/crossChainControllerActionForwardMessage.ts index c15caeb5c3..35359c7ac9 100644 --- a/apps/app/src/plugins/crossChainControllerPlugin/types/crossChainControllerActionForwardMessage.ts +++ b/apps/app/src/plugins/crossChainControllerPlugin/types/crossChainControllerActionForwardMessage.ts @@ -18,4 +18,10 @@ export interface ICrossChainControllerActionForwardMessage * the `_message` parameter of `forwardMessage`. */ nestedActions?: IProposalActionData[]; + /** + * Gas the destination chain may spend executing the message, as a decimal string. Obtained by + * simulating the delivery, and cleared whenever the destination or the actions change so a stale + * figure can never reach the proposal. + */ + gasLimit?: string; } diff --git a/apps/app/src/plugins/crossChainControllerPlugin/types/crossChainControllerPluginSettings.ts b/apps/app/src/plugins/crossChainControllerPlugin/types/crossChainControllerPluginSettings.ts index 8a49fe079d..3105de26d1 100644 --- a/apps/app/src/plugins/crossChainControllerPlugin/types/crossChainControllerPluginSettings.ts +++ b/apps/app/src/plugins/crossChainControllerPlugin/types/crossChainControllerPluginSettings.ts @@ -1,4 +1,5 @@ import type { Address } from 'viem'; +import type { IToken } from '@/modules/finance/api/financeService'; import type { IPluginSettings } from '@/shared/api/daoService'; export interface ICrossChainLaneSettings { @@ -14,6 +15,16 @@ export interface ICrossChainLaneSettings { * Cross-chain adapter, i.e CCIP, on the remote chain. */ remoteAdapter: Address; + /** + * Fee token address, set on local adapter. + */ + feeToken?: Address; + /** + * Metadata of the fee token, indexed by the backend from the `feeToken` address. Undefined when + * the lane has no fee token or the token has not been indexed yet. The total supply is not part + * of the lane projection. + */ + token?: Omit; } export interface ICrossChainControllerPluginSettings extends IPluginSettings { diff --git a/apps/app/src/plugins/crossChainControllerPlugin/utils/crossChainControllerActionUtils/crossChainControllerActionDefinitions.ts b/apps/app/src/plugins/crossChainControllerPlugin/utils/crossChainControllerActionUtils/crossChainControllerActionDefinitions.ts index 62f4c2f9ea..f90aa0127a 100644 --- a/apps/app/src/plugins/crossChainControllerPlugin/utils/crossChainControllerActionUtils/crossChainControllerActionDefinitions.ts +++ b/apps/app/src/plugins/crossChainControllerPlugin/utils/crossChainControllerActionUtils/crossChainControllerActionDefinitions.ts @@ -11,6 +11,7 @@ export const defaultForwardMessage: ICrossChainControllerActionForwardMessage = value: '0', destinationChainId: undefined, nestedActions: [], + gasLimit: undefined, inputData: { function: 'forwardMessage', contract: PluginContractName.CROSS_CHAIN_CONTROLLER, diff --git a/apps/app/src/shared/utils/networkUtils/networkUtils.ts b/apps/app/src/shared/utils/networkUtils/networkUtils.ts index a6cc2786c2..b62ea292e9 100644 --- a/apps/app/src/shared/utils/networkUtils/networkUtils.ts +++ b/apps/app/src/shared/utils/networkUtils/networkUtils.ts @@ -20,6 +20,16 @@ class NetworkUtils { ); }; + /** + * Gets the network matching the given standard chain id. + * @param chainId - Standard EVM chain id to resolve. + * @returns The matching network, or undefined when the chain is not supported by the app. + */ + getNetworkByChainId = (chainId: number): Network | undefined => + this.getSupportedNetworks().find( + (network) => networkDefinitions[network].id === chainId, + ); + /** * Checks if given value is a valid Network. * @param network