Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/app-1032-cross-chain-action-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aragon/app": minor
---

Implement details view for decoded cross-chain execute actions
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<div data-testid="nested-actions-list">
{`nested-count:${(rawActions ?? []).length.toString()} tuple-count:${(rawTuple ?? []).length.toString()} chain-id:${chainId?.toString() ?? ''}`}
</div>
),
}));

describe('<CrossChainControllerForwardMessageDetails /> 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<ICrossChainControllerActionForwardMessage> => {
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<ICrossChainControllerForwardMessageDetailsProps>,
) => {
const completeProps: ICrossChainControllerForwardMessageDetailsProps = {
action: buildAction(),
index: 0,
chainId: 1,
...props,
};

return (
<GukModulesProvider>
<CrossChainControllerForwardMessageDetails {...completeProps} />
</GukModulesProvider>
);
};

const generateNestedAction = (
overrides?: Partial<IProposalAction>,
): 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();
});
});
Original file line number Diff line number Diff line change
@@ -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<IProposalAction>
> {}

/**
* 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 (
<div className="flex w-full flex-col gap-y-6">
<DefinitionList.Container>
<DefinitionList.Item
term={t(
'app.actions.crossChainController.crossChainControllerForwardMessageDetails.chainTerm',
)}
>
{destinationDefinition ? (
<div className="flex items-center gap-2">
<Avatar
size="sm"
src={destinationDefinition.logo}
/>
<span>{destinationDefinition.name}</span>
</div>
) : (
t(
'app.actions.crossChainController.crossChainControllerForwardMessageDetails.chainUnknown',
{ chainId },
)
)}
</DefinitionList.Item>
{formattedGasLimit != null && (
<DefinitionList.Item
term={t(
'app.actions.crossChainController.crossChainControllerForwardMessageDetails.gasLimitTerm',
)}
>
{formattedGasLimit}
</DefinitionList.Item>
)}
</DefinitionList.Container>
<InputContainer
helpText={t(
'app.actions.crossChainController.crossChainControllerForwardMessageDetails.actionsHelpText',
)}
id="crossChainControllerForwardMessageActions"
label={t(
'app.actions.crossChainController.crossChainControllerForwardMessageDetails.actionsLabel',
)}
useCustomWrapper={true}
>
{messageActions == null ? (
<AlertInline
message={t(
'app.actions.crossChainController.crossChainControllerForwardMessageDetails.actionsDecodeError',
)}
variant="warning"
/>
) : (
<CrossChainControllerNestedActionsList
chainId={chainId}
rawActions={actions}
rawTuple={messageActions}
/>
)}
</InputContainer>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { ICrossChainControllerForwardMessageDetailsProps } from './crossChainControllerForwardMessageDetails';
export { CrossChainControllerForwardMessageDetails } from './crossChainControllerForwardMessageDetails';
Loading
Loading