diff --git a/.agents/shared/metrics/hits.jsonl b/.agents/shared/metrics/hits.jsonl index 79d80107bc..9061c94c01 100644 --- a/.agents/shared/metrics/hits.jsonl +++ b/.agents/shared/metrics/hits.jsonl @@ -160,3 +160,4 @@ {"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"} diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index a06b2dd7a4..acacadec14 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -2541,6 +2541,19 @@ "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.", + "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" } } }, 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..e60a480f38 --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.test.tsx @@ -0,0 +1,284 @@ +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 * 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 * as useTokenHook from '@/shared/hooks/useToken'; +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 useTokenSpy = jest.spyOn(useTokenHook, 'useToken'); + const useCreateProposalFormContextSpy = jest.spyOn( + createProposalForm, + 'useCreateProposalFormContext', + ); + const estimateGasLimitSpy = jest.spyOn( + crossChainControllerService, + 'estimateGasLimit', + ); + + const controllerAddress = '0x1111111111111111111111111111111111111111'; + const destinationChainId = 8453; + + 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()); + useTokenSpy.mockReturnValue({ + data: null, + isError: false, + isLoading: false, + }); + useCreateProposalFormContextSpy.mockReturnValue( + {} as ReturnType< + typeof createProposalForm.useCreateProposalFormContext + >, + ); + estimateGasLimitSpy.mockResolvedValue(generateEstimation()); + }); + + afterEach(() => { + form = undefined; + useDaoChainSpy.mockReset(); + useDialogContextSpy.mockReset(); + useTokenSpy.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', + }, + ], + }, + } 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'); + }); +}); 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 e2cdbb4b9b..2b8693542e 100644 --- a/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.tsx +++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.tsx @@ -11,6 +11,7 @@ import { InputContainer, type IProposalActionComponentProps, invariant, + Link, RadioCard, RadioGroup, } from '@aragon/gov-ui-kit'; @@ -29,11 +30,14 @@ 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 { crossChainControllerGas } from '../../../constants/crossChainControllerGas'; import type { ICrossChainControllerActionForwardMessage, ICrossChainControllerPlugin, } from '../../../types'; import { CrossChainControllerProposalActionType } from '../../../types'; +import { GasLimitInput } from './gasLimitInput'; +import { useCrossChainControllerGasLimit } from './useCrossChainControllerGasLimit'; export interface ICrossChainControllerForwardMessageActionProps extends IProposalActionComponentProps< @@ -70,9 +74,6 @@ const messageAbiParameters = [ }, ] as const; -// TODO(APP-1029): expose the gas limit once the product decides between a user input and a quote derived from the destination chain. -const defaultGasLimit = BigInt(1_000_000); - export const CrossChainControllerForwardMessageAction: React.FC< ICrossChainControllerForwardMessageActionProps > = (props) => { @@ -91,7 +92,7 @@ export const CrossChainControllerForwardMessageAction: React.FC< const { t } = useTranslations(); const { open } = useDialogContext(); const { setValue } = useFormContext(); - const { chainId: daoChainId } = useDaoChain({ daoId }); + 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(); @@ -154,6 +155,25 @@ export const CrossChainControllerForwardMessageAction: React.FC< 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, + }, + fieldPrefix: actionFieldName, + }, + ); + const handleDestinationChainChange = (value: string) => onDestinationChainChange(Number(value)); @@ -163,14 +183,14 @@ export const CrossChainControllerForwardMessageAction: React.FC< ({ 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. - const feeTokenAddress = lanes.find( + const destinationLane = lanes.find( ({ chainId }) => chainId === destinationChainId, - )?.feeToken; + ); + // 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. const { data: feeToken } = useToken({ - address: feeTokenAddress, + address: destinationLane?.feeToken, chainId: daoChainId, }); @@ -210,15 +230,34 @@ export const CrossChainControllerForwardMessageAction: React.FC< [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. The field is required, so a proposal can never + // be created in that state. + const encodedGasLimit = BigInt(gasLimit || 0); + const newData = encodeFunctionData({ abi: [forwardMessageAbi], functionName: 'forwardMessage', - args: [BigInt(destinationChainId), defaultGasLimit, encodedMessage], + args: [BigInt(destinationChainId), encodedGasLimit, encodedMessage], }); setValue(`${actionFieldName}.data`, newData); @@ -228,15 +267,19 @@ export const CrossChainControllerForwardMessageAction: React.FC< ); setValue( `${actionFieldName}.inputData.parameters[1].value`, - defaultGasLimit.toString(), + encodedGasLimit.toString(), ); setValue( `${actionFieldName}.inputData.parameters[2].value`, encodedMessage, ); - }, [actionFieldName, destinationChainId, encodedMessage, setValue]); - - const hasNestedActions = nestedActions.length > 0; + }, [ + actionFieldName, + destinationChainId, + encodedMessage, + gasLimit, + setValue, + ]); return (
@@ -327,6 +370,54 @@ export const CrossChainControllerForwardMessageAction: React.FC< )} + {hasNestedActions && ( +
+ + + {(estimationAlert != null || simulationUrl != null) && ( +
+ {estimationAlert != null && ( + + )} + {simulationUrl != null && ( + + {t( + 'app.plugins.crossChainController.crossChainControllerForwardMessageAction.gas.viewSimulation', + )} + + )} +
+ )} +
+ )} + { + 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..b53adf9cd0 --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerGasUtils.ts @@ -0,0 +1,87 @@ +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; +} + +class CrossChainControllerGasUtils { + /** + * 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..1369b184a6 --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/useCrossChainControllerGasLimit.test.ts @@ -0,0 +1,183 @@ +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'), + ), + ); + expect(onGasLimitChange).not.toHaveBeenCalled(); + }); + + 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('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..bbf991022f --- /dev/null +++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/useCrossChainControllerGasLimit.ts @@ -0,0 +1,262 @@ +'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. + */ +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.', + ); + + 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 ( + 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/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/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,