Skip to content

Commit 2da6945

Browse files
committed
feat: add --gas override flag to deploy and write (#402)
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 <gasLimit>` 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).
1 parent 3396474 commit 2da6945

8 files changed

Lines changed: 218 additions & 2 deletions

File tree

‎src/commands/contracts/deploy.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@ import {BaseAction} from "../../lib/actions/BaseAction";
44
import {pathToFileURL} from "url";
55
import {formatStakingAmount} from "genlayer-js";
66
import {buildSync} from "esbuild";
7-
import {ContractFeeCliOptions, parseValidUntil, resolveTransactionFees} from "./fees";
7+
import {ContractFeeCliOptions, parseGasLimit, parseValidUntil, resolveTransactionFees} from "./fees";
88
import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution";
99

1010
export interface DeployOptions extends ContractFeeCliOptions {
1111
contract?: string;
1212
args?: any[];
1313
rpc?: string;
14+
/**
15+
* Explicit outer EVM gas limit for the deployment transaction, bypassing
16+
* eth_estimateGas. See #402.
17+
*/
18+
gas?: string;
1419
}
1520

1621
export interface DeployScriptsOptions {
@@ -133,6 +138,8 @@ export class DeployAction extends BaseAction {
133138

134139
const leaderOnly = false;
135140
const deployParams: any = {code: contractCode, args: options.args, leaderOnly};
141+
const parsedGas = parseGasLimit(options.gas);
142+
if (parsedGas !== undefined) deployParams.gas = parsedGas;
136143
const fees = await resolveTransactionFees(client, options, {
137144
deployTargeted: true,
138145
profileTarget: {kind: "deploy"},

‎src/commands/contracts/fees.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,20 @@ export const resolveTransactionFees = async (
388388
export const parseValidUntil = (options: ContractFeeCliOptions): string | undefined => {
389389
return parseBigNumberishOption(options.validUntil, "--valid-until");
390390
};
391+
392+
/**
393+
* Parses an explicit `--gas` CLI override into a bigint, or returns
394+
* undefined if not provided. Passed straight through to genlayer-js's
395+
* `gas` option on writeContract/deployContract, bypassing eth_estimateGas
396+
* entirely (see #402: an exact estimate can itself cause the outer EVM
397+
* transaction to revert before GenVM is reached).
398+
*/
399+
export const parseGasLimit = (gas: string | undefined): bigint | undefined => {
400+
const parsed = parseBigNumberishOption(gas, "--gas");
401+
if (parsed === undefined) return undefined;
402+
const value = BigInt(parsed);
403+
if (value <= 0n) {
404+
throw new Error("--gas must be a positive integer.");
405+
}
406+
return value;
407+
};

‎src/commands/contracts/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ export function initializeContractsCommands(program: Command) {
118118
.option("--appeal-rounds <count>", "Override fee profile appeal rounds")
119119
.option("--fee-value <wei>", "Fee deposit value to send with the transaction")
120120
.option("--valid-until <unixTimestamp>", "Unix timestamp after which the transaction is invalid")
121+
.option(
122+
"--gas <gasLimit>",
123+
"Explicit outer EVM gas limit, bypassing eth_estimateGas (use if an exact gas estimate reverts the transaction before GenVM)",
124+
)
121125
.option("--args <args...>", ARGS_HELP, parseArg, [])
122126
.action(async (options: DeployOptions) => {
123127
const deployer = new DeployAction();
@@ -149,6 +153,10 @@ export function initializeContractsCommands(program: Command) {
149153
.option("--appeal-rounds <count>", "Override fee profile appeal rounds")
150154
.option("--fee-value <wei>", "Fee deposit value to send with the transaction")
151155
.option("--valid-until <unixTimestamp>", "Unix timestamp after which the transaction is invalid")
156+
.option(
157+
"--gas <gasLimit>",
158+
"Explicit outer EVM gas limit, bypassing eth_estimateGas (use if an exact gas estimate reverts the transaction before GenVM)",
159+
)
152160
.option("--args <args...>", ARGS_HELP, parseArg, [])
153161
.action(async (contractAddress: string, method: string, options: WriteOptions) => {
154162
const writeAction = new WriteAction();

‎src/commands/contracts/write.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@
22
// import type {GenLayerClient} from "genlayer-js/types";
33
import {formatStakingAmount} from "genlayer-js";
44
import {BaseAction} from "../../lib/actions/BaseAction";
5-
import {ContractFeeCliOptions, parseValidUntil, resolveTransactionFees} from "./fees";
5+
import {ContractFeeCliOptions, parseGasLimit, parseValidUntil, resolveTransactionFees} from "./fees";
66
import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution";
77

88
export interface WriteOptions extends ContractFeeCliOptions {
99
args: any[];
1010
rpc?: string;
11+
/**
12+
* Explicit outer EVM gas limit for the transaction, bypassing
13+
* eth_estimateGas. See #402: an exact gas estimate can itself cause the
14+
* outer transaction to revert before GenVM is reached.
15+
*/
16+
gas?: string;
1117
}
1218

1319
export class WriteAction extends BaseAction {
@@ -26,6 +32,7 @@ export class WriteAction extends BaseAction {
2632
appealRounds,
2733
feeValue,
2834
validUntil,
35+
gas,
2936
}: WriteOptions & {
3037
contractAddress: string;
3138
method: string;
@@ -41,6 +48,8 @@ export class WriteAction extends BaseAction {
4148
args,
4249
value: 0n,
4350
};
51+
const parsedGas = parseGasLimit(gas);
52+
if (parsedGas !== undefined) writeParams.gas = parsedGas;
4453
const parsedFees = await resolveTransactionFees(
4554
client,
4655
{fees, feeProfile, feePreset, appealRounds, feeValue, validUntil},

‎tests/actions/deploy.test.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,74 @@ describe("DeployAction", () => {
116116
expect(mockClient.deployContract).toHaveReturnedWith(Promise.resolve("mocked_tx_hash"));
117117
});
118118

119+
test("passes an explicit --gas override through to deployContract as a bigint (#402)", async () => {
120+
const options: DeployOptions = {
121+
contract: "/mocked/contract/path",
122+
args: [1, 2, 3],
123+
gas: "3000000",
124+
};
125+
const contractContent = "contract code";
126+
127+
vi.mocked(fs.existsSync).mockReturnValue(true);
128+
vi.mocked(fs.readFileSync).mockReturnValue(contractContent);
129+
vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash");
130+
vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({
131+
statusName: "ACCEPTED",
132+
txExecutionResultName: "FINISHED_WITH_RETURN",
133+
data: {contract_address: "0xdasdsadasdasdada"},
134+
});
135+
136+
await deployer.deploy(options);
137+
138+
expect(mockClient.deployContract).toHaveBeenCalledWith({
139+
code: contractContent,
140+
args: [1, 2, 3],
141+
leaderOnly: false,
142+
gas: 3_000_000n,
143+
});
144+
});
145+
146+
test("omits gas from deployContract params when --gas is not provided", async () => {
147+
const options: DeployOptions = {
148+
contract: "/mocked/contract/path",
149+
args: [1, 2, 3],
150+
};
151+
const contractContent = "contract code";
152+
153+
vi.mocked(fs.existsSync).mockReturnValue(true);
154+
vi.mocked(fs.readFileSync).mockReturnValue(contractContent);
155+
vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash");
156+
vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({
157+
statusName: "ACCEPTED",
158+
txExecutionResultName: "FINISHED_WITH_RETURN",
159+
data: {contract_address: "0xdasdsadasdasdada"},
160+
});
161+
162+
await deployer.deploy(options);
163+
164+
const calledWith = vi.mocked(mockClient.deployContract).mock.calls[0][0] as any;
165+
expect(calledWith.gas).toBeUndefined();
166+
});
167+
168+
test("rejects an invalid --gas value before calling deployContract", async () => {
169+
const options: DeployOptions = {
170+
contract: "/mocked/contract/path",
171+
args: [1, 2, 3],
172+
gas: "not-a-number",
173+
};
174+
175+
vi.mocked(fs.existsSync).mockReturnValue(true);
176+
vi.mocked(fs.readFileSync).mockReturnValue("contract code");
177+
178+
await deployer.deploy(options);
179+
180+
expect(deployer["failSpinner"]).toHaveBeenCalledWith(
181+
"Error deploying contract",
182+
expect.objectContaining({message: "--gas must be a non-negative integer."}),
183+
);
184+
expect(mockClient.deployContract).not.toHaveBeenCalled();
185+
});
186+
119187
test("deploys contract with fee options", async () => {
120188
const options: DeployOptions = {
121189
contract: "/mocked/contract/path",

‎tests/actions/write.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,76 @@ describe("WriteAction", () => {
103103
});
104104
});
105105

106+
test("passes an explicit --gas override through to writeContract as a bigint (#402)", async () => {
107+
const mockHash = "0xMockedTransactionHash";
108+
const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"};
109+
110+
vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash);
111+
vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt);
112+
113+
await writeAction.write({
114+
contractAddress: "0xMockedContract",
115+
method: "updateData",
116+
args: [42],
117+
gas: "2000000",
118+
} as any);
119+
120+
expect(mockClient.writeContract).toHaveBeenCalledWith({
121+
address: "0xMockedContract",
122+
functionName: "updateData",
123+
args: [42],
124+
value: 0n,
125+
gas: 2_000_000n,
126+
});
127+
});
128+
129+
test("omits gas from writeContract params when --gas is not provided", async () => {
130+
const mockHash = "0xMockedTransactionHash";
131+
const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"};
132+
133+
vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash);
134+
vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt);
135+
136+
await writeAction.write({
137+
contractAddress: "0xMockedContract",
138+
method: "updateData",
139+
args: [42],
140+
} as any);
141+
142+
const calledWith = vi.mocked(mockClient.writeContract).mock.calls[0][0] as any;
143+
expect(calledWith.gas).toBeUndefined();
144+
});
145+
146+
test("rejects a non-numeric --gas value before calling writeContract", async () => {
147+
await writeAction.write({
148+
contractAddress: "0xMockedContract",
149+
method: "updateData",
150+
args: [42],
151+
gas: "not-a-number",
152+
} as any);
153+
154+
expect(writeAction["failSpinner"]).toHaveBeenCalledWith(
155+
"Error during write operation",
156+
expect.objectContaining({message: "--gas must be a non-negative integer."}),
157+
);
158+
expect(mockClient.writeContract).not.toHaveBeenCalled();
159+
});
160+
161+
test("rejects a zero --gas value before calling writeContract", async () => {
162+
await writeAction.write({
163+
contractAddress: "0xMockedContract",
164+
method: "updateData",
165+
args: [42],
166+
gas: "0",
167+
} as any);
168+
169+
expect(writeAction["failSpinner"]).toHaveBeenCalledWith(
170+
"Error during write operation",
171+
expect.objectContaining({message: "--gas must be a positive integer."}),
172+
);
173+
expect(mockClient.writeContract).not.toHaveBeenCalled();
174+
});
175+
106176
test("calls writeContract with fee options", async () => {
107177
const mockHash = "0xMockedTransactionHash";
108178
const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"};

‎tests/commands/deploy.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,24 @@ describe("deploy command", () => {
3030
});
3131
});
3232

33+
test("DeployAction.deploy is called with --gas flag (#402)", async () => {
34+
program.parse([
35+
"node",
36+
"test",
37+
"deploy",
38+
"--contract",
39+
"./path/to/contract",
40+
"--gas",
41+
"3000000",
42+
]);
43+
expect(DeployAction).toHaveBeenCalledTimes(1);
44+
expect(DeployAction.prototype.deploy).toHaveBeenCalledWith({
45+
contract: "./path/to/contract",
46+
args: [],
47+
gas: "3000000",
48+
});
49+
});
50+
3351
test("DeployAction.deploy is called with positional arguments", async () => {
3452
program.parse([
3553
"node",

‎tests/commands/write.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@ describe("write command", () => {
3131
});
3232
});
3333

34+
test("WriteAction.write is called with --gas flag (#402)", async () => {
35+
program.parse([
36+
"node",
37+
"test",
38+
"write",
39+
"0xMockedContract",
40+
"setData",
41+
"--gas",
42+
"2000000",
43+
]);
44+
expect(WriteAction).toHaveBeenCalledTimes(1);
45+
expect(WriteAction.prototype.write).toHaveBeenCalledWith({
46+
contractAddress: "0xMockedContract",
47+
method: "setData",
48+
args: [],
49+
gas: "2000000",
50+
});
51+
});
52+
3453
test("WriteAction.write is called with positional arguments and options", async () => {
3554
program.parse([
3655
"node",

0 commit comments

Comments
 (0)