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,