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/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..ee103a7a86
--- /dev/null
+++ b/apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.test.tsx
@@ -0,0 +1,159 @@
+import { GukModulesProvider } from '@aragon/gov-ui-kit';
+import { render, screen } from '@testing-library/react';
+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 { 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 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('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..81ec4398ca
--- /dev/null
+++ b/apps/app/src/actions/crossChainController/components/crossChainControllerForwardMessageDetails/crossChainControllerForwardMessageDetails.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import {
+ AlertInline,
+ Avatar,
+ 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 { 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 },
+ );
+
+ return (
+
+
+
+ {destinationDefinition ? (
+
+
+
{destinationDefinition.name}
+
+ ) : (
+ t(
+ 'app.actions.crossChainController.crossChainControllerForwardMessageDetails.chainUnknown',
+ { chainId },
+ )
+ )}
+
+ {formattedGasLimit != null && (
+
+ {formattedGasLimit}
+
+ )}
+
+
+ {messageActions == null ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
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..7575f64a88
--- /dev/null
+++ b/apps/app/src/actions/crossChainController/components/crossChainControllerNestedActionsList/crossChainControllerNestedActionsList.test.tsx
@@ -0,0 +1,81 @@
+import { GukModulesProvider } from '@aragon/gov-ui-kit';
+import { render, screen } from '@testing-library/react';
+import type { IProposalAction } from '@/modules/governance/api/governanceService';
+import {
+ CrossChainControllerNestedActionsList,
+ type ICrossChainControllerNestedActionsListProps,
+} from './crossChainControllerNestedActionsList';
+
+describe(' component', () => {
+ 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('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..62b2a5ccb6
--- /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 the length differs from `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, without normalization or 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,
+ );
+
+ 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 acacadec14..25f8bf6c4f 100644
--- a/apps/app/src/assets/locales/en.json
+++ b/apps/app/src/assets/locales/en.json
@@ -21,6 +21,16 @@
"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."
+ }
+ },
"gaugeRegistrar": {
"composer": {
"contractName": "Gauge Registrar",
diff --git a/apps/app/src/modules/governance/components/nestedActionsList/nestedActionsList.tsx b/apps/app/src/modules/governance/components/nestedActionsList/nestedActionsList.tsx
index 1467001728..4a5cd53b77 100644
--- a/apps/app/src/modules/governance/components/nestedActionsList/nestedActionsList.tsx
+++ b/apps/app/src/modules/governance/components/nestedActionsList/nestedActionsList.tsx
@@ -3,10 +3,10 @@
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';
@@ -31,22 +31,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 +45,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/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.ts b/apps/app/src/modules/governance/utils/proposalActionUtils/proposalActionUtils.ts
index 7f389ee766..be7212fda0 100644
--- a/apps/app/src/modules/governance/utils/proposalActionUtils/proposalActionUtils.ts
+++ b/apps/app/src/modules/governance/utils/proposalActionUtils/proposalActionUtils.ts
@@ -5,6 +5,7 @@ import {
type IProposalActionWithdrawToken as IGukProposalActionWithdrawToken,
type IProposalActionUpdateMetadataDaoMetadata,
type IProposalActionUpdateMetadataDaoMetadataLink,
+ ProposalActionTypeNoBasicView,
} from '@aragon/gov-ui-kit';
import {
type AbiStateMutability,
@@ -25,9 +26,39 @@ 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,
+ }));
+
+ /**
+ * 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 their length disagrees with the tuple.
+ */
+ resolveNestedActions = (
+ subActions: IProposalAction[] | undefined,
+ rawTuple: IRawActionTuple[],
+ ): IProposalAction[] => {
+ const hasDecodedMismatch =
+ subActions == null || subActions.length !== rawTuple.length;
+
+ return hasDecodedMismatch
+ ? this.buildRawActionStubs(rawTuple)
+ : subActions;
+ };
+
normalizeActions = (
actions: IProposalAction[],
dao: IDao,
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 2b8693542e..5118cf43af 100644
--- a/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.tsx
+++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.tsx
@@ -30,6 +30,10 @@ import { useDaoChain } from '@/shared/hooks/useDaoChain';
import { useFormField } from '@/shared/hooks/useFormField';
import { useToken } from '@/shared/hooks/useToken';
import { networkUtils } from '@/shared/utils/networkUtils';
+import {
+ forwardMessageAbi,
+ forwardMessageActionsAbi,
+} from '../../../constants/crossChainControllerAbi';
import { crossChainControllerGas } from '../../../constants/crossChainControllerGas';
import type {
ICrossChainControllerActionForwardMessage,
@@ -44,36 +48,6 @@ export interface ICrossChainControllerForwardMessageActionProps
IProposalActionData
> {}
-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;
-
-// The `_message` payload is the ABI encoding of the OSx `Action[]` the destination controller hands
-// to its executor.
-const messageAbiParameters = [
- {
- name: 'actions',
- type: 'tuple[]',
- components: [
- { name: 'to', type: 'address' },
- { name: 'value', type: 'uint256' },
- { name: 'data', type: 'bytes' },
- ],
- },
-] as const;
-
export const CrossChainControllerForwardMessageAction: React.FC<
ICrossChainControllerForwardMessageActionProps
> = (props) => {
@@ -220,7 +194,7 @@ export const CrossChainControllerForwardMessageAction: React.FC<
const encodedMessage = useMemo(
() =>
- encodeAbiParameters(messageAbiParameters, [
+ encodeAbiParameters(forwardMessageActionsAbi, [
nestedActions.map(({ to, value, data }) => ({
to: to as Hex,
value: BigInt(value || 0),
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;