From 5714994990048aca211947456398fc5ed7966543 Mon Sep 17 00:00:00 2001 From: ygd58 Date: Fri, 7 Aug 2026 11:31:23 +0000 Subject: [PATCH] feat: add explicit gas override to writeContract/deployContract (#402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An exact eth_estimateGas result can itself cause the outer addTransaction EVM transaction to revert before GenVM is reached — confirmed on Bradbury (#402): gas limit 1,319,997 (== a fresh eth_estimateGas result) reverted twice; replaying identical calldata with 2,000,000 succeeded and finalized normally. No caller-side way existed to work around a bad estimate. Note: this repo's automatic gas headroom (withTransactionGasHeadroom, a 2x margin) already covers the specific numbers in #402's repro, but it isn't published to npm yet — I checked the actual genlayer-js@1.1.8 tarball from the registry and confirmed the headroom code isn't in it. This change adds the second, complementary part of #402's ask: a way to bypass estimation entirely for cases the headroom doesn't cover. Added an optional `gas?: bigint` field to writeContract and deployContract. When provided, it's used exactly as given — no eth_estimateGas call, no headroom markup — since an explicit override is the caller stating what to use. Both local-account (signTransaction) and external-wallet (eth_sendTransaction) send paths respect it; both flow through the same shared `_sendTransaction`/`sendWithEncodedData` helper that writeContract and deployContract already share, so there was a single spot to plumb this through for both. Tests: added 4 cases to tests/contracts-actions.test.ts using the existing setupWriteContractHarness — override bypasses estimation entirely, no-override path still estimates+applies headroom (asserting the exact 2,639,994 = 1,319,997 * 2x from #402's numbers), and the override threads through deployContract the same way. Full suite: 87 passed. eslint: clean. --- src/contracts/actions.ts | 41 +++++++++++++------ src/types/clients.ts | 10 +++++ tests/contracts-actions.test.ts | 70 +++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index be1afb3..0927825 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -356,6 +356,7 @@ export const contractActions = (client: GenLayerClient, publicCli consensusMaxRotations?: number; validUntil?: BigNumberish; fees?: TransactionFeeOptions; + gas?: bigint; }): Promise<`0x${string}`> => { const { account, @@ -368,6 +369,7 @@ export const contractActions = (client: GenLayerClient, publicCli consensusMaxRotations = client.chain.defaultConsensusMaxRotations, validUntil, fees, + gas, } = args; const data = [calldata.encode(calldata.makeCalldataObject(functionName, callArgs, kwargs)), leaderOnly]; @@ -394,6 +396,7 @@ export const contractActions = (client: GenLayerClient, publicCli publicClient, transactionVariants, senderAccount, + gas, }); }, /** Deploys a new intelligent contract to GenLayer. Returns the transaction hash. */ @@ -406,6 +409,7 @@ export const contractActions = (client: GenLayerClient, publicCli consensusMaxRotations?: number; validUntil?: BigNumberish; fees?: TransactionFeeOptions; + gas?: bigint; }) => { const { account, @@ -416,6 +420,7 @@ export const contractActions = (client: GenLayerClient, publicCli consensusMaxRotations = client.chain.defaultConsensusMaxRotations, validUntil, fees, + gas, } = args; const data = [ @@ -446,6 +451,7 @@ export const contractActions = (client: GenLayerClient, publicCli publicClient, transactionVariants, senderAccount, + gas, }); }, /** Returns the active fee price policy used to build user-side caps. */ @@ -1988,11 +1994,18 @@ const _sendTransaction = async ({ publicClient, transactionVariants, senderAccount, + gas: gasOverride, }: { client: GenLayerClient; publicClient: PublicClient; transactionVariants: EncodedTransactionVariant[]; senderAccount?: Account; + /** + * Explicit outer EVM gas limit, bypassing `eth_estimateGas` and the + * automatic headroom below. Used as-is with no further markup, since an + * explicit override is the caller stating what to use. + */ + gas?: bigint; }) => { if (!client.chain.consensusMainContract?.address) { throw new Error(`Consensus main contract address not found in chain config for "${client.chain.name}".`); @@ -2046,18 +2059,22 @@ const _sendTransaction = async ({ const sendWithEncodedData = async (transactionVariant: EncodedTransactionVariant) => { let estimatedGas: bigint; let gasEstimationError: string | undefined; - try { - estimatedGas = await client.estimateTransactionGas({ - from: validatedSenderAccount.address, - to: client.chain.consensusMainContract?.address as Address, - data: transactionVariant.encodedData, - value: transactionVariant.value, - }); - estimatedGas = withTransactionGasHeadroom(estimatedGas); - } catch (err) { - gasEstimationError = stringifyRpcError(err); - console.error("Gas estimation failed, using default 200_000:", err); - estimatedGas = 200_000n; + if (gasOverride !== undefined) { + estimatedGas = gasOverride; + } else { + try { + estimatedGas = await client.estimateTransactionGas({ + from: validatedSenderAccount.address, + to: client.chain.consensusMainContract?.address as Address, + data: transactionVariant.encodedData, + value: transactionVariant.value, + }); + estimatedGas = withTransactionGasHeadroom(estimatedGas); + } catch (err) { + gasEstimationError = stringifyRpcError(err); + console.error("Gas estimation failed, using default 200_000:", err); + estimatedGas = 200_000n; + } } // For local accounts, build transaction request directly to avoid viem's diff --git a/src/types/clients.ts b/src/types/clients.ts index 336a449..9628362 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -82,6 +82,14 @@ export type GenLayerClient = Omit< consensusMaxRotations?: number; validUntil?: BigNumberish; fees?: TransactionFeeOptions; + /** + * Explicit outer EVM gas limit for the `addTransaction` call, bypassing + * `eth_estimateGas` and the automatic safety headroom entirely. Useful + * when gas estimation is unreliable for a given RPC/network and the + * estimated-but-exact limit reverts the outer transaction before GenVM + * even sees it. + */ + gas?: bigint; }) => Promise; simulateWriteContract: < RawReturn extends boolean | undefined = undefined, @@ -110,6 +118,8 @@ export type GenLayerClient = Omit< consensusMaxRotations?: number; validUntil?: BigNumberish; fees?: TransactionFeeOptions; + /** Explicit outer EVM gas limit, bypassing `eth_estimateGas`. See `writeContract`'s `gas` option. */ + gas?: bigint; }) => Promise<`0x${string}`>; getTransaction: (args: {hash: TransactionHash}) => Promise; getCurrentNonce: (args: {address: Address}) => Promise; diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 98a5ab6..a2c592c 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -461,6 +461,76 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(encodedData.slice(0, 10)).toBe(selectorForV5); }); + it("uses the explicit gas override as-is, bypassing eth_estimateGas entirely (#402)", async () => { + const signTransaction = vi.fn().mockRejectedValue(new Error("stop_after_encoding")); + const {actions, estimateTransactionGas} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_V5, + signTransactionMock: signTransaction, + }); + + await expect( + actions.writeContract({ + address: RECIPIENT_ADDRESS, + functionName: "ping", + value: 0n, + gas: 2_000_000n, + }), + ).rejects.toThrow("stop_after_encoding"); + + // eth_estimateGas must not be called at all when an override is supplied — + // an exact estimate is exactly the failure mode #402 reports, so the + // override path must not go anywhere near it. + expect(estimateTransactionGas).not.toHaveBeenCalled(); + + const txRequest = signTransaction.mock.calls[0][0]; + expect(txRequest.gas).toBe(2_000_000n); + }); + + it("still estimates and applies headroom when no gas override is given (#402)", async () => { + const signTransaction = vi.fn().mockRejectedValue(new Error("stop_after_encoding")); + const {actions, estimateTransactionGas} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_V5, + signTransactionMock: signTransaction, + }); + estimateTransactionGas.mockResolvedValue(1_319_997n); + + await expect( + actions.writeContract({ + address: RECIPIENT_ADDRESS, + functionName: "ping", + value: 0n, + }), + ).rejects.toThrow("stop_after_encoding"); + + expect(estimateTransactionGas).toHaveBeenCalledTimes(1); + + const txRequest = signTransaction.mock.calls[0][0]; + // 1_319_997 * 20_000bps / 10_000 = 2_639_994 — well above the 2_000_000 + // that #402 confirmed succeeds against the exact 1_319_997 estimate that + // reverted twice. + expect(txRequest.gas).toBe(2_639_994n); + }); + + it("threads the gas override through deployContract the same way as writeContract (#402)", async () => { + const signTransaction = vi.fn().mockRejectedValue(new Error("stop_after_encoding")); + const {actions, estimateTransactionGas} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_V5, + signTransactionMock: signTransaction, + }); + + await expect( + actions.deployContract({ + code: "0x1234", + args: [], + gas: 3_000_000n, + }), + ).rejects.toThrow("stop_after_encoding"); + + expect(estimateTransactionGas).not.toHaveBeenCalled(); + const txRequest = signTransaction.mock.calls[0][0]; + expect(txRequest.gas).toBe(3_000_000n); + }); + it("encodes addTransaction with 6 args when ABI has 6 inputs", async () => { const {actions, estimateTransactionGas} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_V6,