Skip to content

Commit 162ba15

Browse files
authored
Merge pull request #347 from BitGo/fix/hemi-batcher-gas-estimation
fix: refactor batcher gas config and fix Hemi deployment hang
2 parents 76a0450 + 723d89b commit 162ba15

2 files changed

Lines changed: 195 additions & 75 deletions

File tree

config/batcherDeployGasConfig.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { FeeData } from 'ethers';
2+
import { ethers } from 'hardhat';
3+
import { CHAIN_IDS } from './chainIds';
4+
5+
/** Transaction override fields accepted by ethers v6 ContractFactory.deploy(). */
6+
export type TxOverrides = {
7+
gasLimit?: number;
8+
maxFeePerGas?: bigint;
9+
maxPriorityFeePerGas?: bigint;
10+
gasPrice?: bigint;
11+
};
12+
13+
/**
14+
* Gas strategies for batcher contract deployment.
15+
*
16+
* default
17+
* Hardhat auto-detects the fee model and estimates gas via eth_estimateGas.
18+
* Suitable for any chain whose node accepts EIP-1559 (type-2) estimation params.
19+
*
20+
* eip1559-fees
21+
* Hardhat auto-estimates gasLimit, but maxFeePerGas / maxPriorityFeePerGas are
22+
* supplied explicitly. Use when a chain's eth_feeHistory is unreliable but
23+
* eth_estimateGas still works correctly with type-2 params.
24+
*
25+
* manual-gas
26+
* Both fee params and gasLimit are supplied explicitly.
27+
* Required when eth_estimateGas rejects type-2 params (causing Hardhat's internal
28+
* estimation to hang or fail). Gas is pre-estimated with a plain { from, data }
29+
* call, which all nodes accept regardless of fee model.
30+
* Use forceLegacy: true when the node also rejects type-2 send transactions
31+
* (i.e. it advertises EIP-1559 fee data but only mines legacy type-0 txs).
32+
*/
33+
export type GasStrategy = 'default' | 'eip1559-fees' | 'manual-gas';
34+
35+
type ChainGasConfig =
36+
| { strategy: 'default' }
37+
| { strategy: 'eip1559-fees' }
38+
| {
39+
strategy: 'manual-gas';
40+
/** Minimum priority fee in gwei; prevents zero-tip txs on chains that require it. Default: 1. */
41+
minPriorityFeeGwei?: number;
42+
/** Extra gas added on top of the estimate as a percentage, e.g. 20 = +20%. Default: 20. */
43+
gasBufferPct?: number;
44+
/** Cap the final gasLimit to 95% of the latest block's gas limit. Default: false. */
45+
capToBlockGasLimit?: boolean;
46+
/**
47+
* Force a legacy type-0 transaction (gasPrice only) even when the node reports
48+
* EIP-1559 fee data. Required for chains whose mempool advertises EIP-1559 support
49+
* but silently drops type-2 transactions (e.g. Hemi).
50+
*/
51+
forceLegacy?: boolean;
52+
};
53+
54+
/**
55+
* Per-chain gas configuration for batcher contract deployment.
56+
* Add an entry here whenever a new chain requires non-default gas handling.
57+
*/
58+
const CHAIN_GAS_CONFIGS: Partial<Record<number, ChainGasConfig>> = {
59+
// eip1559-fees — node fee data is unreliable; supply explicit EIP-1559 params,
60+
// let Hardhat estimate the gasLimit via eth_estimateGas.
61+
[CHAIN_IDS.MONAD_TESTNET]: { strategy: 'eip1559-fees' },
62+
[CHAIN_IDS.WORLD]: { strategy: 'eip1559-fees' },
63+
[CHAIN_IDS.WORLD_TESTNET]: { strategy: 'eip1559-fees' },
64+
[CHAIN_IDS.SONEIUM]: { strategy: 'eip1559-fees' },
65+
[CHAIN_IDS.SONEIUM_TESTNET]: { strategy: 'eip1559-fees' },
66+
[CHAIN_IDS.WEMIX]: { strategy: 'eip1559-fees' },
67+
[CHAIN_IDS.WEMIX_TESTNET]: { strategy: 'eip1559-fees' },
68+
69+
// manual-gas — eth_estimateGas hangs or fails with type-2 params on these chains;
70+
// gas is pre-estimated with a plain { from, data } call and passed explicitly.
71+
//
72+
// Hemi: node advertises EIP-1559 fee data but only mines legacy (type-0) txs,
73+
// so forceLegacy ensures we always send gasPrice instead of maxFeePerGas.
74+
[CHAIN_IDS.HEMIETH]: { strategy: 'manual-gas', forceLegacy: true },
75+
[CHAIN_IDS.HEMIETH_TESTNET]: { strategy: 'manual-gas', forceLegacy: true },
76+
// Somnia: block gas limit is low; cap the gasLimit to avoid exceeding it.
77+
[CHAIN_IDS.SOMNIA]: { strategy: 'manual-gas', capToBlockGasLimit: true },
78+
[CHAIN_IDS.SOMNIA_TESTNET]: {
79+
strategy: 'manual-gas',
80+
capToBlockGasLimit: true
81+
}
82+
};
83+
84+
export function getChainGasConfig(chainId: number): ChainGasConfig {
85+
return CHAIN_GAS_CONFIGS[chainId] ?? { strategy: 'default' };
86+
}
87+
88+
/**
89+
* Resolves the final TxOverrides for Batcher.deploy() based on the chain's gas strategy.
90+
*
91+
* @param chainId - Current network chain ID
92+
* @param feeData - Result of provider.getFeeData()
93+
* @param deployer - The signer that will send the deploy tx
94+
* @param deployTxReq - The unsigned deploy transaction (from ContractFactory.getDeployTransaction)
95+
* @param fromAddress - The deployer's address string
96+
*/
97+
export async function resolveGasOverrides(
98+
chainId: number,
99+
feeData: FeeData,
100+
deployer: Awaited<ReturnType<typeof ethers.getSigner>>,
101+
deployTxReq: { data?: string | null },
102+
fromAddress: string
103+
): Promise<TxOverrides | undefined> {
104+
const config = getChainGasConfig(chainId);
105+
106+
if (config.strategy === 'default') {
107+
return undefined;
108+
}
109+
110+
if (config.strategy === 'eip1559-fees') {
111+
return {
112+
maxFeePerGas: feeData.maxFeePerGas ?? feeData.gasPrice ?? undefined,
113+
maxPriorityFeePerGas:
114+
feeData.maxPriorityFeePerGas ?? feeData.gasPrice ?? undefined
115+
};
116+
}
117+
118+
// manual-gas: pre-estimate gas with a plain { from, data } call (no fee-model params),
119+
// then pass the result as an explicit gasLimit. This bypasses Hardhat's internal
120+
// eth_estimateGas, which sends type-2 params and hangs on chains like Hemi and Somnia.
121+
const minPriorityFee =
122+
BigInt(config.minPriorityFeeGwei ?? 1) * 1_000_000_000n;
123+
const gasBufferPct = BigInt(config.gasBufferPct ?? 20);
124+
125+
const has1559 =
126+
feeData.maxFeePerGas != null && feeData.maxPriorityFeePerGas != null;
127+
128+
let overrides: TxOverrides;
129+
130+
if (has1559 && !config.forceLegacy) {
131+
const priority =
132+
(feeData.maxPriorityFeePerGas ?? 0n) > 0n
133+
? (feeData.maxPriorityFeePerGas as bigint)
134+
: minPriorityFee;
135+
const base = feeData.maxFeePerGas ?? feeData.gasPrice ?? minPriorityFee;
136+
const maxFee = base > priority * 2n ? base : priority * 2n;
137+
overrides = { maxFeePerGas: maxFee, maxPriorityFeePerGas: priority };
138+
} else {
139+
overrides = {
140+
gasPrice: feeData.gasPrice ?? 6_000_000_000n
141+
};
142+
}
143+
144+
// Estimate gas without fee-model params so the call succeeds on nodes that
145+
// reject type-2 estimation requests.
146+
const est = await deployer.estimateGas({
147+
...deployTxReq,
148+
from: fromAddress
149+
});
150+
const estWithBuffer = (est * (100n + gasBufferPct)) / 100n;
151+
let chosenGasLimit = Number(estWithBuffer);
152+
153+
if (config.capToBlockGasLimit) {
154+
const latestBlock = await ethers.provider.getBlock('latest');
155+
if (latestBlock?.gasLimit) {
156+
const blockLimit = Number(latestBlock.gasLimit);
157+
chosenGasLimit = Math.min(chosenGasLimit, Math.floor(blockLimit * 0.95));
158+
}
159+
}
160+
161+
overrides.gasLimit = chosenGasLimit;
162+
return overrides;
163+
}

scripts/deployBatcherContract.ts

Lines changed: 32 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,11 @@ import { logger, waitAndVerify, checkSufficientBalance } from '../deployUtils';
44
import fs from 'fs';
55
import { setupBigBlocksForBatcherDeployment } from './enableBigBlocks';
66
import { isBigBlocksSupported } from '../config/bigBlocksConfig';
7-
8-
// Minimal tx override type compatible with ethers v6
9-
type TxOverrides = {
10-
gasLimit?: number;
11-
maxFeePerGas?: bigint;
12-
maxPriorityFeePerGas?: bigint;
13-
gasPrice?: bigint;
14-
};
7+
import {
8+
TxOverrides,
9+
resolveGasOverrides,
10+
getChainGasConfig
11+
} from '../config/batcherDeployGasConfig';
1512

1613
async function main() {
1714
logger.step('🚀 Starting Batcher Contract Deployment 🚀');
@@ -31,13 +28,15 @@ async function main() {
3128
throw new Error('No signers available');
3229
}
3330

34-
// New: dynamic deployer selection (env override / fallback / funded account)
31+
// Use BATCHER_DEPLOYER_INDEX to override the signer used for deployment.
32+
// Defaults to index 2, which maps to PRIVATE_KEY_FOR_BATCHER_CONTRACT_DEPLOYMENT in hardhat.config.ts.
33+
// Falls back to the most funded account if the desired index has no balance.
3534
const desiredIndex = process.env.BATCHER_DEPLOYER_INDEX
3635
? Number(process.env.BATCHER_DEPLOYER_INDEX)
37-
: 2; // legacy default
36+
: 2;
3837
let batcherDeployer = signers[desiredIndex] || signers[signers.length - 1];
3938

40-
// Gather balances to ensure we pick a funded account (EOA must have funds)
39+
// Fetch balances upfront to validate the selected deployer and log diagnostics.
4140
const signerInfos = await Promise.all(
4241
signers.map(async (s, i) => {
4342
const addr = await s.getAddress();
@@ -103,10 +102,7 @@ async function main() {
103102
// --- 2. Gas Parameter Handling ---
104103
logger.step('2. Configuring gas parameters for the transaction...');
105104

106-
// Maintain the legacy behavior: selectively set fee overrides for specific chains
107-
const eip1559Chains = [10143, 480, 4801, 1946, 1868, 1114, 1112, 1111, 50312];
108105
const feeData = await ethers.provider.getFeeData();
109-
let gasOverrides: TxOverrides | undefined = undefined;
110106

111107
// --- 3. Contract Deployment ---
112108
logger.step("3. Deploying the 'Batcher' contract...");
@@ -118,98 +114,59 @@ async function main() {
118114
const erc20BatchLimit = 256;
119115
const nativeBatchLimit = 256;
120116

121-
// Build the deploy transaction request for estimation
117+
// Build the unsigned deploy transaction so we can pre-estimate gas before deploying.
122118
const deployTxReq = await Batcher.getDeployTransaction(
123119
transferGasLimit,
124120
erc20BatchLimit,
125121
nativeBatchLimit
126122
);
127123

128-
if (Number(chainId) === 50312 || Number(chainId) === 5031) {
129-
// Somnia testnet & mainnet quirks: ensure non-zero priority fee; estimate gas and cap to block gas limit
130-
const latestBlock = await ethers.provider.getBlock('latest');
131-
const has1559 =
132-
feeData.maxFeePerGas != null && feeData.maxPriorityFeePerGas != null;
133-
134-
if (has1559) {
135-
const minPriority = 1_000_000_000n; // 1 gwei
136-
const priority =
137-
(feeData.maxPriorityFeePerGas ?? 0n) > 0n
138-
? (feeData.maxPriorityFeePerGas as bigint)
139-
: minPriority;
140-
const base = feeData.maxFeePerGas ?? 0n;
141-
const maxFee = base > priority * 2n ? base : priority * 2n;
142-
gasOverrides = { maxFeePerGas: maxFee, maxPriorityFeePerGas: priority };
143-
logger.info(
144-
`EIP-1559 params (Somnia): maxFeePerGas=${String(
145-
maxFee
146-
)}, maxPriorityFeePerGas=${String(priority)}`
147-
);
148-
} else {
149-
const fallbackGasPrice = feeData.gasPrice ?? 6_000_000_000n; // 6 gwei fallback
150-
gasOverrides = { gasPrice: fallbackGasPrice };
151-
logger.info(
152-
`Legacy gasPrice (Somnia): gasPrice=${String(fallbackGasPrice)}`
153-
);
154-
}
124+
const gasConfig = getChainGasConfig(Number(chainId));
125+
logger.info(`Gas strategy for chain ${chainId}: '${gasConfig.strategy}'`);
155126

156-
// Estimate gas for contract creation
157-
const est = await batcherDeployer.estimateGas({
158-
...deployTxReq,
159-
from: address,
160-
...gasOverrides
161-
});
162-
163-
const estWithBuffer = (est * 120n) / 100n; // +20%
164-
let chosenGasLimit = Number(estWithBuffer);
165-
if (latestBlock?.gasLimit) {
166-
const blockLimit = Number(latestBlock.gasLimit);
167-
chosenGasLimit = Math.min(chosenGasLimit, Math.floor(blockLimit * 0.95));
168-
}
169-
gasOverrides.gasLimit = chosenGasLimit;
170-
logger.info(
171-
`Estimated gas: ${est.toString()}, using gasLimit=${chosenGasLimit}`
172-
);
173-
} else if (eip1559Chains.includes(Number(chainId))) {
174-
gasOverrides = {
175-
maxFeePerGas: feeData.maxFeePerGas ?? feeData.gasPrice ?? undefined,
176-
maxPriorityFeePerGas:
177-
feeData.maxPriorityFeePerGas ?? feeData.gasPrice ?? undefined
178-
};
127+
const gasOverrides = await resolveGasOverrides(
128+
Number(chainId),
129+
feeData,
130+
batcherDeployer,
131+
deployTxReq,
132+
address
133+
);
134+
135+
if (gasOverrides) {
179136
logger.info(
180-
`Gas params set: maxFeePerGas=${String(
181-
gasOverrides.maxFeePerGas ?? ''
182-
)}, maxPriorityFeePerGas=${String(
183-
gasOverrides.maxPriorityFeePerGas ?? ''
184-
)}, gasLimit=<auto>`
137+
`Gas overrides resolved: ${JSON.stringify(gasOverrides, (_, v) =>
138+
typeof v === 'bigint' ? v.toString() : v
139+
)}`
185140
);
186141
} else {
187-
logger.info(`Using default gas parameters for this network.`);
142+
logger.info('Gas overrides: none (Hardhat auto-detection)');
188143
}
189144

190145
// --- 3.5. Balance Check ---
191146
logger.step('3.5. Checking deployer balance before deployment...');
192147

193-
// Estimate gas cost for the deployment
148+
// Estimate gas and effective price to verify the deployer has sufficient funds.
149+
// For chains with manual-gas overrides the estimate reuses the resolved gasLimit;
150+
// for default chains Hardhat auto-estimates here.
194151
const gasEstimate = await batcherDeployer.estimateGas({
195152
...deployTxReq,
196153
from: address,
197154
...gasOverrides
198155
});
199156

200-
// Calculate total cost
157+
// Use the effective gas price from overrides when present, otherwise fall back to
158+
// the live fee data (maxFeePerGas represents the worst-case spend for EIP-1559 txs).
201159
let gasPrice: bigint;
202160
if (gasOverrides?.gasPrice) {
203161
gasPrice = gasOverrides.gasPrice;
204162
} else if (gasOverrides?.maxFeePerGas) {
205163
gasPrice = gasOverrides.maxFeePerGas;
206164
} else {
207-
gasPrice = feeData.gasPrice ?? 1_000_000_000n; // 1 gwei fallback
165+
gasPrice = feeData.gasPrice ?? 1_000_000_000n;
208166
}
209167

210168
const estimatedCost = gasEstimate * gasPrice;
211169

212-
// Check if we have 1.5x the required amount
213170
await checkSufficientBalance(address, estimatedCost, 'Batcher');
214171

215172
let batcher: Contract;

0 commit comments

Comments
 (0)