Skip to content

Commit d1d90d2

Browse files
authored
refactor(sdk): extract shared gas estimation utilities (#7922)
1 parent 93679c0 commit d1d90d2

6 files changed

Lines changed: 173 additions & 71 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperlane-xyz/sdk": minor
3+
---
4+
5+
Extracted shared gas estimation utilities: `estimateHandleGasForRecipient()` for `handle()` calls and `estimateCallGas()` for individual contract calls. Added `HyperlaneCore.estimateHandleGas()` accepting minimal params. Refactored `InterchainAccount.estimateIcaHandleGas()` to use shared utilities.

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,6 @@ yalc.lock
4444

4545
# .yarn as we use pnpm now
4646
.yarn
47-
.sisyphus/
47+
48+
.opencode
49+
.sisyphus

typescript/sdk/src/core/HyperlaneCore.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { DerivedIsmConfig } from '../ism/types.js';
3838
import { MultiProvider } from '../providers/MultiProvider.js';
3939
import { RouterConfig } from '../router/types.js';
4040
import { ChainMap, ChainName, OwnableConfig } from '../types.js';
41+
import { estimateHandleGasForRecipient } from '../utils/gas.js';
4142
import { findMatchingLogEvents } from '../utils/logUtils.js';
4243

4344
import { CoreFactories, coreFactories } from './contracts.js';
@@ -257,20 +258,58 @@ export class HyperlaneCore extends HyperlaneApp<CoreFactories> {
257258
// This estimation overrides transaction.from which requires a funded signer
258259
// on ZkSync-based chains. We catch estimation failures and return '0' to
259260
// allow the caller to handle gas estimation differently.
261+
return this.estimateHandleGas({
262+
destination: this.getDestination(message),
263+
recipient: bytes32ToAddress(message.parsed.recipient),
264+
origin: message.parsed.origin,
265+
sender: message.parsed.sender,
266+
body: message.parsed.body,
267+
});
268+
}
269+
270+
/**
271+
* Estimates gas for calling handle() on a recipient contract.
272+
*
273+
* This is a flexible utility that accepts minimal parameters (destination,
274+
* recipient, origin, sender, body) instead of requiring a full DispatchedMessage.
275+
* Use this when you have message components but not a complete DispatchedMessage object.
276+
*
277+
* @param params - Object containing:
278+
* - destination: The destination chain name
279+
* - recipient: The recipient contract address (or any IMessageRecipient implementation like ICA router)
280+
* - origin: The origin domain ID
281+
* - sender: The sender address (as bytes32 string)
282+
* - body: The message body (as hex string)
283+
* - mailbox: Optional mailbox address override (defaults to chain's configured mailbox)
284+
* @returns Gas estimate as a string, or '0' if estimation fails
285+
*/
286+
async estimateHandleGas(params: {
287+
destination: ChainName;
288+
recipient: Address;
289+
origin: number;
290+
sender: string;
291+
body: string;
292+
mailbox?: Address;
293+
}): Promise<string> {
260294
try {
261-
return (
262-
await this.getRecipient(message).estimateGas.handle(
263-
message.parsed.origin,
264-
message.parsed.sender,
265-
message.parsed.body,
266-
{ from: this.getAddresses(this.getDestination(message)).mailbox },
267-
)
268-
).toString();
269-
} catch (error) {
270-
this.logger.debug(
271-
{ error, destination: this.getDestination(message) },
272-
'Failed to estimate handle gas, returning 0',
295+
const provider = this.multiProvider.getProvider(params.destination);
296+
const mailbox =
297+
params.mailbox ?? this.getAddresses(params.destination).mailbox;
298+
const recipientContract = IMessageRecipient__factory.connect(
299+
params.recipient,
300+
provider,
273301
);
302+
303+
const gasEstimate = await estimateHandleGasForRecipient({
304+
recipient: recipientContract,
305+
origin: params.origin,
306+
sender: params.sender,
307+
body: params.body,
308+
mailbox,
309+
});
310+
311+
return gasEstimate?.toString() ?? '0';
312+
} catch {
274313
return '0';
275314
}
276315
}

typescript/sdk/src/middleware/account/InterchainAccount.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,10 @@ describe('InterchainAccount.getCallRemote', () => {
159159
describe('InterchainAccount.estimateIcaHandleGas', () => {
160160
const chain = TestChainName.test1;
161161
const destination = TestChainName.test2;
162-
const ICA_HANDLE_GAS_FALLBACK = BigNumber.from(200_000);
163162
const ICA_OVERHEAD = BigNumber.from(50_000);
164163
const PER_CALL_OVERHEAD = BigNumber.from(5_000);
165164
const PER_CALL_FALLBACK = BigNumber.from(50_000);
165+
const ICA_HANDLE_GAS_FALLBACK = BigNumber.from(200_000);
166166

167167
let sandbox: sinon.SinonSandbox;
168168
let multiProvider: MultiProvider;
@@ -293,10 +293,9 @@ describe('InterchainAccount.estimateIcaHandleGas', () => {
293293
expect(result.toString()).to.equal(expectedWithBuffer.toString());
294294
});
295295

296-
it('returns static 200k fallback when Promise.all fails', async () => {
296+
it('returns static 200k fallback when getProvider fails', async () => {
297297
mockDestRouter.estimateGas.handle.rejects(new Error('handle failed'));
298298

299-
// Make getProvider throw to cause Promise.all to fail
300299
(multiProvider.getProvider as sinon.SinonStub).throws(
301300
new Error('provider error'),
302301
);

typescript/sdk/src/middleware/account/InterchainAccount.ts

Lines changed: 50 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ import { MultiProvider } from '../../providers/MultiProvider.js';
3131
import { CallData as SdkCallData } from '../../providers/transactions/types.js';
3232
import { RouterApp } from '../../router/RouterApps.js';
3333
import { ChainMap, ChainName } from '../../types.js';
34+
import {
35+
estimateCallGas,
36+
estimateHandleGasForRecipient,
37+
} from '../../utils/gas.js';
3438

3539
import {
3640
InterchainAccountFactories,
@@ -39,6 +43,8 @@ import {
3943
import { AccountConfig, GetCallRemoteSettings } from './types.js';
4044

4145
const IGP_DEFAULT_GAS = BigNumber.from(50_000);
46+
const ICA_OVERHEAD = BigNumber.from(50_000);
47+
const PER_CALL_OVERHEAD = BigNumber.from(5_000);
4248
const ICA_HANDLE_GAS_FALLBACK = BigNumber.from(200_000);
4349

4450
export class InterchainAccount extends RouterApp<InterchainAccountFactories> {
@@ -230,43 +236,53 @@ export class InterchainAccount extends RouterApp<InterchainAccountFactories> {
230236
);
231237

232238
try {
233-
const gasEstimate = await destinationRouter.estimateGas.handle(
234-
originDomain,
235-
addressToBytes32(localRouterAddress),
236-
messageBody,
237-
{ from: await destinationRouter.mailbox() },
239+
const mailbox = await destinationRouter.mailbox();
240+
const gasEstimate = await estimateHandleGasForRecipient({
241+
recipient: destinationRouter,
242+
origin: originDomain,
243+
sender: addressToBytes32(localRouterAddress),
244+
body: messageBody,
245+
mailbox,
246+
});
247+
248+
if (gasEstimate) {
249+
return addBufferToGasLimit(gasEstimate);
250+
}
251+
} catch {
252+
// Fall through to individual call estimation
253+
}
254+
255+
this.logger.warn(
256+
{ destination },
257+
'Failed to estimate ICA handle gas, trying individual call estimation',
258+
);
259+
260+
try {
261+
const provider = this.multiProvider.getProvider(destination);
262+
const individualEstimates = await Promise.all(
263+
formattedCalls.map((call) =>
264+
estimateCallGas({
265+
provider,
266+
to: bytes32ToAddress(call.to),
267+
data: call.data,
268+
value: call.value,
269+
}),
270+
),
271+
);
272+
const totalGas = individualEstimates.reduce(
273+
(sum, gas) => sum.add(gas),
274+
BigNumber.from(0),
275+
);
276+
const overhead = ICA_OVERHEAD.add(
277+
PER_CALL_OVERHEAD.mul(formattedCalls.length),
238278
);
239-
return addBufferToGasLimit(gasEstimate);
240-
} catch (error) {
279+
return addBufferToGasLimit(totalGas.add(overhead));
280+
} catch {
241281
this.logger.warn(
242-
{ error, destination },
243-
'Failed to estimate ICA handle gas, trying individual call estimation',
282+
{ destination },
283+
'Individual call estimation also failed, using static fallback',
244284
);
245-
246-
try {
247-
const individualEstimates = await Promise.all(
248-
formattedCalls.map((call) =>
249-
this.estimateIndividualCallGas(destination, call),
250-
),
251-
);
252-
const totalGas = individualEstimates.reduce(
253-
(sum, gas) => sum.add(gas),
254-
BigNumber.from(0),
255-
);
256-
// Overhead: ICA deployment check + multicall dispatch + message decoding
257-
const ICA_OVERHEAD = BigNumber.from(50_000);
258-
const PER_CALL_OVERHEAD = BigNumber.from(5_000);
259-
const overhead = ICA_OVERHEAD.add(
260-
PER_CALL_OVERHEAD.mul(formattedCalls.length),
261-
);
262-
return addBufferToGasLimit(totalGas.add(overhead));
263-
} catch (fallbackError) {
264-
this.logger.warn(
265-
{ error: fallbackError, destination },
266-
'Individual call estimation also failed, using static fallback',
267-
);
268-
return ICA_HANDLE_GAS_FALLBACK;
269-
}
285+
return ICA_HANDLE_GAS_FALLBACK;
270286
}
271287
}
272288

@@ -353,27 +369,6 @@ export class InterchainAccount extends RouterApp<InterchainAccountFactories> {
353369
return parsed ? BigNumber.from(parsed.gasLimit) : null;
354370
}
355371

356-
/**
357-
* Estimate gas for a single call on the destination chain.
358-
* Used as fallback when full handle() estimation fails.
359-
*/
360-
private async estimateIndividualCallGas(
361-
destination: string,
362-
call: { to: string; value: BigNumber; data: string },
363-
): Promise<BigNumber> {
364-
const provider = this.multiProvider.getProvider(destination);
365-
const PER_CALL_FALLBACK = BigNumber.from(50_000);
366-
try {
367-
return await provider.estimateGas({
368-
to: bytes32ToAddress(call.to),
369-
data: call.data,
370-
value: call.value,
371-
});
372-
} catch {
373-
return PER_CALL_FALLBACK;
374-
}
375-
}
376-
377372
// general helper for different overloaded callRemote functions
378373
// can override the gasLimit by StandardHookMetadata.overrideGasLimit for optional hookMetadata here
379374
async callRemote({

typescript/sdk/src/utils/gas.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { BigNumber, providers } from 'ethers';
2+
3+
import { IMessageRecipient } from '@hyperlane-xyz/core';
4+
import { Address } from '@hyperlane-xyz/utils';
5+
6+
export const DEFAULT_CALL_GAS_FALLBACK = BigNumber.from(50_000);
7+
8+
export interface EstimateHandleGasParams {
9+
origin: number;
10+
sender: string;
11+
body: string;
12+
mailbox: Address;
13+
recipient: IMessageRecipient;
14+
}
15+
16+
export interface EstimateCallGasParams {
17+
provider: providers.Provider;
18+
to: Address;
19+
data: string;
20+
value?: BigNumber;
21+
fallback?: BigNumber;
22+
}
23+
24+
/**
25+
* Estimates gas for calling handle() on a recipient contract.
26+
* Returns null if estimation fails (e.g., call would revert).
27+
*/
28+
export async function estimateHandleGasForRecipient(
29+
params: EstimateHandleGasParams,
30+
): Promise<BigNumber | null> {
31+
try {
32+
// await required for catch to handle promise rejection
33+
return await params.recipient.estimateGas.handle(
34+
params.origin,
35+
params.sender,
36+
params.body,
37+
{ from: params.mailbox },
38+
);
39+
} catch {
40+
return null;
41+
}
42+
}
43+
44+
/**
45+
* Estimates gas for a single contract call.
46+
* Returns fallback value (default 50k) if estimation fails.
47+
*/
48+
export async function estimateCallGas(
49+
params: EstimateCallGasParams,
50+
): Promise<BigNumber> {
51+
const fallback = params.fallback ?? DEFAULT_CALL_GAS_FALLBACK;
52+
try {
53+
// await required for catch to handle promise rejection
54+
return await params.provider.estimateGas({
55+
to: params.to,
56+
data: params.data,
57+
value: params.value,
58+
});
59+
} catch {
60+
return fallback;
61+
}
62+
}

0 commit comments

Comments
 (0)