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",