From ed0a77ced2a06a6d3e3e372c0939517a2633c135 Mon Sep 17 00:00:00 2001 From: Edgars Date: Mon, 31 Aug 2026 10:31:08 +0100 Subject: [PATCH] feat: add contract transaction gas override Expose --gas for deploy and write, keep it separate from GenLayer fee budgets, and pin the genlayer-js commit that provides the corresponding SDK runtime support. --- docs/api-references/contracts/deploy.mdx | 1 + docs/api-references/contracts/write.mdx | 1 + package-lock.json | 6 +++--- package.json | 2 +- src/commands/contracts/deploy.ts | 5 ++++- src/commands/contracts/index.ts | 8 ++++++++ src/commands/contracts/transaction.ts | 19 +++++++++++++++++++ src/commands/contracts/write.ts | 6 +++++- tests/actions/deploy.test.ts | 22 ++++++++++++++++++++++ tests/actions/transaction.test.ts | 23 +++++++++++++++++++++++ tests/actions/write.test.ts | 22 ++++++++++++++++++++++ tests/commands/deploy.test.ts | 3 +++ tests/commands/write.test.ts | 3 +++ 13 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 src/commands/contracts/transaction.ts create mode 100644 tests/actions/transaction.test.ts diff --git a/docs/api-references/contracts/deploy.mdx b/docs/api-references/contracts/deploy.mdx index 1fc24809..49497f47 100644 --- a/docs/api-references/contracts/deploy.mdx +++ b/docs/api-references/contracts/deploy.mdx @@ -20,6 +20,7 @@ Deploy intelligent contracts | | --appeal-rounds <count> | Override fee profile appeal rounds | No | | | | --fee-value <wei> | Fee deposit value to send with the transaction | No | | | | --valid-until <unixTimestamp> | Unix timestamp after which the transaction is invalid | No | | +| | --gas <units> | Outer EVM transaction gas limit; separate from GenLayer fee budgets | No | | | | --args <args...> | Contract arguments. Supported types: | No | | | | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/contracts/write.mdx b/docs/api-references/contracts/write.mdx index a6542d4a..2ea0fd8e 100644 --- a/docs/api-references/contracts/write.mdx +++ b/docs/api-references/contracts/write.mdx @@ -24,6 +24,7 @@ Sends a transaction to a contract method that modifies the state | | --appeal-rounds <count> | Override fee profile appeal rounds | No | | | | --fee-value <wei> | Fee deposit value to send with the transaction | No | | | | --valid-until <unixTimestamp> | Unix timestamp after which the transaction is invalid | No | | +| | --gas <units> | Outer EVM transaction gas limit; separate from GenLayer fee budgets | No | | | | --args <args...> | Contract arguments. Supported types: | No | | | | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/package-lock.json b/package-lock.json index 57da0433..2203ac90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#6f1273885567ff5cda77b7459edfd6666c5859d0", + "genlayer-js": "github:genlayerlabs/genlayer-js#869ef09a0a54c2f47a5ecf59a6caa6c2d0db6208", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", @@ -5683,8 +5683,8 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#6f1273885567ff5cda77b7459edfd6666c5859d0", - "integrity": "sha512-ts4KjgqO/qR8pKiCOp+g5FKbAKUyLLIFTuXX6+ZjFYKOhTHPP3kL+CR4siVKp4YGVWWk+/DWeVXgCMZOBo1QLA==", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#869ef09a0a54c2f47a5ecf59a6caa6c2d0db6208", + "integrity": "sha512-OAwtbuPO+81wOmHim7bI5YBaEBEzAU3rN0PZu8PHO8dC25l3dwLkiMZnPmL9D1AH4JKPVyN12PmJ6m65XWry1g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 02f0ee9f..d21823d8 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#6f1273885567ff5cda77b7459edfd6666c5859d0", + "genlayer-js": "github:genlayerlabs/genlayer-js#869ef09a0a54c2f47a5ecf59a6caa6c2d0db6208", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index 9abc875d..20dacf2d 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -6,9 +6,10 @@ import {pathToFileURL} from "url"; import {formatStakingAmount} from "genlayer-js"; import {buildSync} from "esbuild"; import {ContractFeeCliOptions, parseValidUntil, resolveTransactionFees} from "./fees"; +import {ContractTransactionCliOptions, parseGas} from "./transaction"; import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution"; -export interface DeployOptions extends ContractFeeCliOptions { +export interface DeployOptions extends ContractFeeCliOptions, ContractTransactionCliOptions { contract?: string; args?: any[]; rpc?: string; @@ -153,6 +154,7 @@ export class DeployAction extends BaseAction { const leaderOnly = false; const deployParams: any = {code: contractCode, args: options.args, leaderOnly}; + const gas = parseGas(options.gas); const fees = await resolveTransactionFees(client, options, { deployTargeted: true, profileTarget: {kind: "deploy"}, @@ -160,6 +162,7 @@ export class DeployAction extends BaseAction { const validUntil = parseValidUntil(options); if (fees) deployParams.fees = fees; if (validUntil !== undefined) deployParams.validUntil = validUntil; + if (gas !== undefined) deployParams.gas = gas; this.setSpinnerText("Starting contract deployment..."); if (fees?.feeValue !== undefined) { diff --git a/src/commands/contracts/index.ts b/src/commands/contracts/index.ts index f4909eb3..27ecff26 100644 --- a/src/commands/contracts/index.ts +++ b/src/commands/contracts/index.ts @@ -120,6 +120,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 ", + "Outer EVM transaction gas limit; separate from GenLayer fee budgets", + ) .option("--args ", ARGS_HELP, parseArg, []), ).action(async (options: DeployOptions) => { const deployer = new DeployAction(); @@ -152,6 +156,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 ", + "Outer EVM transaction gas limit; separate from GenLayer fee budgets", + ) .option("--args ", ARGS_HELP, parseArg, []), ).action(async (contractAddress: string, method: string, options: WriteOptions) => { const writeAction = new WriteAction(); diff --git a/src/commands/contracts/transaction.ts b/src/commands/contracts/transaction.ts new file mode 100644 index 00000000..b27cd5fe --- /dev/null +++ b/src/commands/contracts/transaction.ts @@ -0,0 +1,19 @@ +export interface ContractTransactionCliOptions { + /** Gas limit for the outer EVM transaction submitted to ConsensusMain. */ + gas?: string; +} + +export const parseGas = (value: string | undefined): bigint | undefined => { + if (value === undefined) return undefined; + + const trimmed = value.trim(); + if (!/^(0x[0-9a-fA-F]+|[0-9]+)$/.test(trimmed)) { + throw new Error("--gas must be a positive integer."); + } + + const gas = BigInt(trimmed); + if (gas <= 0n) { + throw new Error("--gas must be a positive integer."); + } + return gas; +}; diff --git a/src/commands/contracts/write.ts b/src/commands/contracts/write.ts index 882e3c3b..8432a620 100644 --- a/src/commands/contracts/write.ts +++ b/src/commands/contracts/write.ts @@ -3,9 +3,10 @@ import {formatStakingAmount} from "genlayer-js"; import {BaseAction} from "../../lib/actions/BaseAction"; import {ContractFeeCliOptions, parseValidUntil, resolveTransactionFees} from "./fees"; +import {ContractTransactionCliOptions, parseGas} from "./transaction"; import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution"; -export interface WriteOptions extends ContractFeeCliOptions { +export interface WriteOptions extends ContractFeeCliOptions, ContractTransactionCliOptions { args: any[]; rpc?: string; wallet?: "keystore" | "browser"; @@ -28,6 +29,7 @@ export class WriteAction extends BaseAction { appealRounds, feeValue, validUntil, + gas, }: WriteOptions & { contractAddress: string; method: string; @@ -45,6 +47,7 @@ export class WriteAction extends BaseAction { args, value: 0n, }; + const parsedGas = parseGas(gas); const parsedFees = await resolveTransactionFees( client, {fees, feeProfile, feePreset, appealRounds, feeValue, validUntil}, @@ -60,6 +63,7 @@ export class WriteAction extends BaseAction { }); if (parsedFees) writeParams.fees = parsedFees; if (parsedValidUntil !== undefined) writeParams.validUntil = parsedValidUntil; + if (parsedGas !== undefined) writeParams.gas = parsedGas; if (parsedFees?.feeValue !== undefined) { const parsedFeeValue = BigInt(parsedFees.feeValue); this.log(`Fee deposit: ${parsedFeeValue.toString()} wei (~${formatStakingAmount(parsedFeeValue)})`); diff --git a/tests/actions/deploy.test.ts b/tests/actions/deploy.test.ts index d3ebb609..3db87bf8 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -119,6 +119,28 @@ describe("DeployAction", () => { expect(mockClient.deployContract).toHaveReturnedWith(Promise.resolve("mocked_tx_hash")); }); + test("passes an explicit outer EVM gas limit to deployContract", async () => { + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + 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({ + contract: "/mocked/contract/path", + gas: "31000000", + }); + + expect(mockClient.deployContract).toHaveBeenCalledWith({ + code: "contract code", + args: undefined, + leaderOnly: false, + gas: 31_000_000n, + }); + }); + test("deploys contract with fee options", async () => { const options: DeployOptions = { contract: "/mocked/contract/path", diff --git a/tests/actions/transaction.test.ts b/tests/actions/transaction.test.ts new file mode 100644 index 00000000..6a17d9ca --- /dev/null +++ b/tests/actions/transaction.test.ts @@ -0,0 +1,23 @@ +import {describe, expect, test} from "vitest"; +import {parseGas} from "../../src/commands/contracts/transaction"; + +describe("parseGas", () => { + test.each([ + ["32000000", 32_000_000n], + [" 42 ", 42n], + ["0x5208", 21_000n], + ])("parses %s", (value, expected) => { + expect(parseGas(value)).toBe(expected); + }); + + test("omits gas when the option is absent", () => { + expect(parseGas(undefined)).toBeUndefined(); + }); + + test.each(["0", "0x0", "-1", "1.5", "32_000_000", "nope"])( + "rejects invalid gas %s", + value => { + expect(() => parseGas(value)).toThrow("--gas must be a positive integer"); + }, + ); +}); diff --git a/tests/actions/write.test.ts b/tests/actions/write.test.ts index b5a9bbab..f66498e9 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -103,6 +103,28 @@ describe("WriteAction", () => { }); }); + test("passes an explicit outer EVM gas limit to writeContract", 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: [], + gas: "32000000", + }); + + expect(mockClient.writeContract).toHaveBeenCalledWith({ + address: "0xMockedContract", + functionName: "updateData", + args: [], + value: 0n, + gas: 32_000_000n, + }); + }); + 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..a20d87e3 100644 --- a/tests/commands/deploy.test.ts +++ b/tests/commands/deploy.test.ts @@ -66,6 +66,8 @@ describe("deploy command", () => { "4", "--valid-until", "999", + "--gas", + "32000000", ]); expect(DeployAction.prototype.deploy).toHaveBeenCalledWith({ @@ -74,6 +76,7 @@ describe("deploy command", () => { fees, feeValue: "4", validUntil: "999", + gas: "32000000", }); }); diff --git a/tests/commands/write.test.ts b/tests/commands/write.test.ts index 7e562312..19d79c3a 100644 --- a/tests/commands/write.test.ts +++ b/tests/commands/write.test.ts @@ -68,6 +68,8 @@ describe("write command", () => { "4", "--valid-until", "999", + "--gas", + "32000000", ]); expect(WriteAction.prototype.write).toHaveBeenCalledWith({ @@ -77,6 +79,7 @@ describe("write command", () => { fees, feeValue: "4", validUntil: "999", + gas: "32000000", }); });