From 2da69457ec10fc2a8ef84245bb5ee1fa9c4ef788 Mon Sep 17 00:00:00 2001 From: ygd58 Date: Fri, 7 Aug 2026 11:32:08 +0000 Subject: [PATCH] feat: add --gas override flag to deploy and write (#402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Bradbury, `genlayer write` used the exact eth_estimateGas result as the outer EVM gas limit, and the transaction reverted twice before GenVM was ever reached; identical calldata succeeded when replayed with a larger explicit gas limit. The CLI exposed no way to override gas estimation for either deploy or write. Adds `--gas ` to both commands, threaded through to genlayer-js's writeContract/deployContract `gas` option (see genlayer-js PR #205, which this depends on — this PR's tests use a mocked genlayer-js client so they don't need that change merged first, but the flag has no runtime effect against genlayer-js versions that don't understand the `gas` field yet). Added a `parseGasLimit` helper next to the existing `parseValidUntil` in fees.ts, reusing the same `parseBigNumberishOption` validation (non-negative integer, decimal or 0x-hex) plus a positive-value check specific to gas (zero isn't a meaningful gas limit). Tests: 9 new cases across tests/actions/{write,deploy}.test.ts (valid override passed through as bigint, omitted when not provided, invalid value rejected before the SDK call, zero rejected) and tests/commands/{write,deploy}.test.ts (CLI flag parsing reaches the action). Full suite: 585 passed (52 files), no regressions. TypeScript: 0 new errors in any file I touched (`npx tsc --noEmit` shows pre-existing, unrelated errors in staking/vesting/keychain code from genlayer-cli's source being ahead of its published genlayer-js dependency — none in write.ts, deploy.ts, fees.ts, or commands/index.ts). --- src/commands/contracts/deploy.ts | 9 +++- src/commands/contracts/fees.ts | 17 ++++++++ src/commands/contracts/index.ts | 8 ++++ src/commands/contracts/write.ts | 11 ++++- tests/actions/deploy.test.ts | 68 +++++++++++++++++++++++++++++++ tests/actions/write.test.ts | 70 ++++++++++++++++++++++++++++++++ tests/commands/deploy.test.ts | 18 ++++++++ tests/commands/write.test.ts | 19 +++++++++ 8 files changed, 218 insertions(+), 2 deletions(-) diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index 845831c6..2c4d2be2 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -4,13 +4,18 @@ import {BaseAction} from "../../lib/actions/BaseAction"; import {pathToFileURL} from "url"; import {formatStakingAmount} from "genlayer-js"; import {buildSync} from "esbuild"; -import {ContractFeeCliOptions, parseValidUntil, resolveTransactionFees} from "./fees"; +import {ContractFeeCliOptions, parseGasLimit, parseValidUntil, resolveTransactionFees} from "./fees"; import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution"; export interface DeployOptions extends ContractFeeCliOptions { contract?: string; args?: any[]; rpc?: string; + /** + * Explicit outer EVM gas limit for the deployment transaction, bypassing + * eth_estimateGas. See #402. + */ + gas?: string; } export interface DeployScriptsOptions { @@ -133,6 +138,8 @@ export class DeployAction extends BaseAction { const leaderOnly = false; const deployParams: any = {code: contractCode, args: options.args, leaderOnly}; + const parsedGas = parseGasLimit(options.gas); + if (parsedGas !== undefined) deployParams.gas = parsedGas; const fees = await resolveTransactionFees(client, options, { deployTargeted: true, profileTarget: {kind: "deploy"}, diff --git a/src/commands/contracts/fees.ts b/src/commands/contracts/fees.ts index f276e96d..b780ae98 100644 --- a/src/commands/contracts/fees.ts +++ b/src/commands/contracts/fees.ts @@ -388,3 +388,20 @@ export const resolveTransactionFees = async ( export const parseValidUntil = (options: ContractFeeCliOptions): string | undefined => { return parseBigNumberishOption(options.validUntil, "--valid-until"); }; + +/** + * Parses an explicit `--gas` CLI override into a bigint, or returns + * undefined if not provided. Passed straight through to genlayer-js's + * `gas` option on writeContract/deployContract, bypassing eth_estimateGas + * entirely (see #402: an exact estimate can itself cause the outer EVM + * transaction to revert before GenVM is reached). + */ +export const parseGasLimit = (gas: string | undefined): bigint | undefined => { + const parsed = parseBigNumberishOption(gas, "--gas"); + if (parsed === undefined) return undefined; + const value = BigInt(parsed); + if (value <= 0n) { + throw new Error("--gas must be a positive integer."); + } + return value; +}; diff --git a/src/commands/contracts/index.ts b/src/commands/contracts/index.ts index c18b5df2..48d58e42 100644 --- a/src/commands/contracts/index.ts +++ b/src/commands/contracts/index.ts @@ -118,6 +118,10 @@ export function initializeContractsCommands(program: Command) { .option("--appeal-rounds ", "Override fee profile appeal rounds") .option("--fee-value ", "Fee deposit value to send with the transaction") .option("--valid-until ", "Unix timestamp after which the transaction is invalid") + .option( + "--gas ", + "Explicit outer EVM gas limit, bypassing eth_estimateGas (use if an exact gas estimate reverts the transaction before GenVM)", + ) .option("--args ", ARGS_HELP, parseArg, []) .action(async (options: DeployOptions) => { const deployer = new DeployAction(); @@ -149,6 +153,10 @@ export function initializeContractsCommands(program: Command) { .option("--appeal-rounds ", "Override fee profile appeal rounds") .option("--fee-value ", "Fee deposit value to send with the transaction") .option("--valid-until ", "Unix timestamp after which the transaction is invalid") + .option( + "--gas ", + "Explicit outer EVM gas limit, bypassing eth_estimateGas (use if an exact gas estimate reverts the transaction before GenVM)", + ) .option("--args ", ARGS_HELP, parseArg, []) .action(async (contractAddress: string, method: string, options: WriteOptions) => { const writeAction = new WriteAction(); diff --git a/src/commands/contracts/write.ts b/src/commands/contracts/write.ts index 42622b8a..edfabfb9 100644 --- a/src/commands/contracts/write.ts +++ b/src/commands/contracts/write.ts @@ -2,12 +2,18 @@ // import type {GenLayerClient} from "genlayer-js/types"; import {formatStakingAmount} from "genlayer-js"; import {BaseAction} from "../../lib/actions/BaseAction"; -import {ContractFeeCliOptions, parseValidUntil, resolveTransactionFees} from "./fees"; +import {ContractFeeCliOptions, parseGasLimit, parseValidUntil, resolveTransactionFees} from "./fees"; import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution"; export interface WriteOptions extends ContractFeeCliOptions { args: any[]; rpc?: string; + /** + * Explicit outer EVM gas limit for the transaction, bypassing + * eth_estimateGas. See #402: an exact gas estimate can itself cause the + * outer transaction to revert before GenVM is reached. + */ + gas?: string; } export class WriteAction extends BaseAction { @@ -26,6 +32,7 @@ export class WriteAction extends BaseAction { appealRounds, feeValue, validUntil, + gas, }: WriteOptions & { contractAddress: string; method: string; @@ -41,6 +48,8 @@ export class WriteAction extends BaseAction { args, value: 0n, }; + const parsedGas = parseGasLimit(gas); + if (parsedGas !== undefined) writeParams.gas = parsedGas; const parsedFees = await resolveTransactionFees( client, {fees, feeProfile, feePreset, appealRounds, feeValue, validUntil}, diff --git a/tests/actions/deploy.test.ts b/tests/actions/deploy.test.ts index b6453055..51e5a7bc 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -116,6 +116,74 @@ describe("DeployAction", () => { expect(mockClient.deployContract).toHaveReturnedWith(Promise.resolve("mocked_tx_hash")); }); + test("passes an explicit --gas override through to deployContract as a bigint (#402)", async () => { + const options: DeployOptions = { + contract: "/mocked/contract/path", + args: [1, 2, 3], + gas: "3000000", + }; + const contractContent = "contract code"; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(contractContent); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResultName: "FINISHED_WITH_RETURN", + data: {contract_address: "0xdasdsadasdasdada"}, + }); + + await deployer.deploy(options); + + expect(mockClient.deployContract).toHaveBeenCalledWith({ + code: contractContent, + args: [1, 2, 3], + leaderOnly: false, + gas: 3_000_000n, + }); + }); + + test("omits gas from deployContract params when --gas is not provided", async () => { + const options: DeployOptions = { + contract: "/mocked/contract/path", + args: [1, 2, 3], + }; + const contractContent = "contract code"; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(contractContent); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResultName: "FINISHED_WITH_RETURN", + data: {contract_address: "0xdasdsadasdasdada"}, + }); + + await deployer.deploy(options); + + const calledWith = vi.mocked(mockClient.deployContract).mock.calls[0][0] as any; + expect(calledWith.gas).toBeUndefined(); + }); + + test("rejects an invalid --gas value before calling deployContract", async () => { + const options: DeployOptions = { + contract: "/mocked/contract/path", + args: [1, 2, 3], + gas: "not-a-number", + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + + await deployer.deploy(options); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({message: "--gas must be a non-negative integer."}), + ); + expect(mockClient.deployContract).not.toHaveBeenCalled(); + }); + test("deploys contract with fee options", async () => { const options: DeployOptions = { contract: "/mocked/contract/path", diff --git a/tests/actions/write.test.ts b/tests/actions/write.test.ts index 22938c05..e8feafac 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -103,6 +103,76 @@ describe("WriteAction", () => { }); }); + test("passes an explicit --gas override through to writeContract as a bigint (#402)", async () => { + const mockHash = "0xMockedTransactionHash"; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); + + await writeAction.write({ + contractAddress: "0xMockedContract", + method: "updateData", + args: [42], + gas: "2000000", + } as any); + + expect(mockClient.writeContract).toHaveBeenCalledWith({ + address: "0xMockedContract", + functionName: "updateData", + args: [42], + value: 0n, + gas: 2_000_000n, + }); + }); + + test("omits gas from writeContract params when --gas is not provided", async () => { + const mockHash = "0xMockedTransactionHash"; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); + + await writeAction.write({ + contractAddress: "0xMockedContract", + method: "updateData", + args: [42], + } as any); + + const calledWith = vi.mocked(mockClient.writeContract).mock.calls[0][0] as any; + expect(calledWith.gas).toBeUndefined(); + }); + + test("rejects a non-numeric --gas value before calling writeContract", async () => { + await writeAction.write({ + contractAddress: "0xMockedContract", + method: "updateData", + args: [42], + gas: "not-a-number", + } as any); + + expect(writeAction["failSpinner"]).toHaveBeenCalledWith( + "Error during write operation", + expect.objectContaining({message: "--gas must be a non-negative integer."}), + ); + expect(mockClient.writeContract).not.toHaveBeenCalled(); + }); + + test("rejects a zero --gas value before calling writeContract", async () => { + await writeAction.write({ + contractAddress: "0xMockedContract", + method: "updateData", + args: [42], + gas: "0", + } as any); + + expect(writeAction["failSpinner"]).toHaveBeenCalledWith( + "Error during write operation", + expect.objectContaining({message: "--gas must be a positive integer."}), + ); + expect(mockClient.writeContract).not.toHaveBeenCalled(); + }); + test("calls writeContract with fee options", async () => { const mockHash = "0xMockedTransactionHash"; const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; diff --git a/tests/commands/deploy.test.ts b/tests/commands/deploy.test.ts index 85a493f8..9a84c227 100644 --- a/tests/commands/deploy.test.ts +++ b/tests/commands/deploy.test.ts @@ -30,6 +30,24 @@ describe("deploy command", () => { }); }); + test("DeployAction.deploy is called with --gas flag (#402)", async () => { + program.parse([ + "node", + "test", + "deploy", + "--contract", + "./path/to/contract", + "--gas", + "3000000", + ]); + expect(DeployAction).toHaveBeenCalledTimes(1); + expect(DeployAction.prototype.deploy).toHaveBeenCalledWith({ + contract: "./path/to/contract", + args: [], + gas: "3000000", + }); + }); + test("DeployAction.deploy is called with positional arguments", async () => { program.parse([ "node", diff --git a/tests/commands/write.test.ts b/tests/commands/write.test.ts index 7e562312..6815eef7 100644 --- a/tests/commands/write.test.ts +++ b/tests/commands/write.test.ts @@ -31,6 +31,25 @@ describe("write command", () => { }); }); + test("WriteAction.write is called with --gas flag (#402)", async () => { + program.parse([ + "node", + "test", + "write", + "0xMockedContract", + "setData", + "--gas", + "2000000", + ]); + expect(WriteAction).toHaveBeenCalledTimes(1); + expect(WriteAction.prototype.write).toHaveBeenCalledWith({ + contractAddress: "0xMockedContract", + method: "setData", + args: [], + gas: "2000000", + }); + }); + test("WriteAction.write is called with positional arguments and options", async () => { program.parse([ "node",