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;