From 5d008844ad0b97760bbd025ed5aac61b41b2b881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Tue, 9 Jun 2026 23:41:26 +0100 Subject: [PATCH 1/6] fix: fail CLI writes on execution errors (#345) --- src/commands/contracts/deploy.ts | 3 ++ src/commands/contracts/execution.ts | 75 +++++++++++++++++++++++++++++ src/commands/contracts/write.ts | 3 ++ tests/actions/deploy.test.ts | 34 +++++++++++++ tests/actions/write.test.ts | 31 ++++++++++-- 5 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 src/commands/contracts/execution.ts diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index 198f9c8..3dc0fff 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -5,6 +5,7 @@ import {pathToFileURL} from "url"; import {TransactionStatus} from "genlayer-js/types"; import {buildSync} from "esbuild"; import {ContractFeeCliOptions, parseTransactionFees, parseValidUntil} from "./fees"; +import {assertSuccessfulExecution} from "./execution"; export interface DeployOptions extends ContractFeeCliOptions { contract?: string; @@ -147,7 +148,9 @@ export class DeployAction extends BaseAction { retries: 50, interval: 5000, status: TransactionStatus.ACCEPTED, + fullTransaction: true, }); + assertSuccessfulExecution("Deployment", hash, result); this.log("Deployment Receipt:", result); diff --git a/src/commands/contracts/execution.ts b/src/commands/contracts/execution.ts new file mode 100644 index 0000000..0778417 --- /dev/null +++ b/src/commands/contracts/execution.ts @@ -0,0 +1,75 @@ +import {ExecutionResult} from "genlayer-js/types"; + +function directField(value: unknown, names: string[]): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + for (const name of names) { + if (Object.prototype.hasOwnProperty.call(record, name)) return record[name]; + } + return undefined; +} + +function normalizeExecutionResult(value: unknown): ExecutionResult | undefined { + if (value == null) return undefined; + if (typeof value === "string") { + const normalized = value.toUpperCase(); + if (normalized === ExecutionResult.FINISHED_WITH_RETURN) return ExecutionResult.FINISHED_WITH_RETURN; + if (normalized === ExecutionResult.FINISHED_WITH_ERROR) return ExecutionResult.FINISHED_WITH_ERROR; + if (normalized === ExecutionResult.NOT_VOTED) return ExecutionResult.NOT_VOTED; + if (normalized === "SUCCESS") return ExecutionResult.FINISHED_WITH_RETURN; + if (normalized === "ERROR" || normalized === "FAILURE") return ExecutionResult.FINISHED_WITH_ERROR; + if (/^\d+$/.test(normalized)) return normalizeExecutionResult(Number(normalized)); + } + if (typeof value === "number" || typeof value === "bigint") { + const numeric = Number(value); + if (numeric === 0) return ExecutionResult.NOT_VOTED; + if (numeric === 1) return ExecutionResult.FINISHED_WITH_RETURN; + if (numeric === 2) return ExecutionResult.FINISHED_WITH_ERROR; + } + return undefined; +} + +function transactionExecutionResult(receipt: unknown): ExecutionResult | undefined { + const record = receipt && typeof receipt === "object" && !Array.isArray(receipt) + ? receipt as Record + : {}; + const data = directField(record, ["data"]); + const consensusData = + directField(data, ["consensus_data", "consensusData"]) ?? + directField(record, ["consensus_data", "consensusData"]); + const leaderReceipt = directField(consensusData, ["leader_receipt", "leaderReceipt"]); + const firstLeaderReceipt = Array.isArray(leaderReceipt) ? leaderReceipt[0] : leaderReceipt; + const genvmResult = directField(firstLeaderReceipt, ["genvm_result", "genvmResult"]); + + const candidates = [ + directField(record, ["txExecutionResultName", "tx_execution_result_name"]), + directField(record, ["txExecutionResult", "tx_execution_result"]), + directField(data, ["txExecutionResultName", "tx_execution_result_name"]), + directField(data, ["txExecutionResult", "tx_execution_result"]), + directField(firstLeaderReceipt, ["execution_result", "executionResult"]), + directField(genvmResult, ["execution_result", "executionResult"]), + directField(data, ["execution_result", "executionResult"]), + ]; + for (const candidate of candidates) { + const result = normalizeExecutionResult(candidate); + if (result !== undefined) return result; + } + return undefined; +} + +export function assertSuccessfulExecution( + operation: string, + hash: unknown, + receipt: unknown, +): void { + const result = transactionExecutionResult(receipt); + if (result === ExecutionResult.FINISHED_WITH_RETURN) return; + + if (result === undefined) { + throw new Error( + `${operation} ${String(hash)} reached consensus status but did not expose an execution result.`, + ); + } + + throw new Error(`${operation} ${String(hash)} execution failed with ${result}.`); +} diff --git a/src/commands/contracts/write.ts b/src/commands/contracts/write.ts index 3187098..100e293 100644 --- a/src/commands/contracts/write.ts +++ b/src/commands/contracts/write.ts @@ -2,6 +2,7 @@ // import type {GenLayerClient} from "genlayer-js/types"; import {BaseAction} from "../../lib/actions/BaseAction"; import {ContractFeeCliOptions, parseTransactionFees, parseValidUntil} from "./fees"; +import {assertSuccessfulExecution} from "./execution"; export interface WriteOptions extends ContractFeeCliOptions { args: any[]; @@ -53,7 +54,9 @@ export class WriteAction extends BaseAction { hash, retries: 100, interval: 5000, + fullTransaction: true, }); + assertSuccessfulExecution("Write", hash, result); this.succeedSpinner("Write operation successfully executed", result); } catch (error) { this.failSpinner("Error during write operation", error); diff --git a/tests/actions/deploy.test.ts b/tests/actions/deploy.test.ts index 00c2603..a0db7c5 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -81,6 +81,7 @@ describe("DeployAction", () => { vi.mocked(fs.readFileSync).mockReturnValue(contractContent); vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + txExecutionResultName: "FINISHED_WITH_RETURN", data: {contract_address: "0xdasdsadasdasdada"}, }); @@ -92,6 +93,13 @@ describe("DeployAction", () => { args: [1, 2, 3], leaderOnly: false, }); + expect(mockClient.waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: "mocked_tx_hash", + retries: 50, + interval: 5000, + status: "ACCEPTED", + fullTransaction: true, + }); expect(mockClient.deployContract).toHaveReturnedWith(Promise.resolve("mocked_tx_hash")); }); @@ -119,6 +127,7 @@ describe("DeployAction", () => { vi.mocked(fs.readFileSync).mockReturnValue(contractContent); vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + txExecutionResultName: "FINISHED_WITH_RETURN", data: {contract_address: "0xdasdsadasdasdada"}, }); @@ -144,6 +153,30 @@ describe("DeployAction", () => { }); }); + test("fails when deployment reaches consensus but execution fails", async () => { + const options: DeployOptions = { + contract: "/mocked/contract/path", + args: [1, 2, 3], + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + txExecutionResultName: "FINISHED_WITH_ERROR", + data: {contract_address: "0xdasdsadasdasdada"}, + }); + + await deployer.deploy(options); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining("execution failed with FINISHED_WITH_ERROR"), + }), + ); + }); + test("throws error for missing contract", async () => { const options: DeployOptions = {}; @@ -387,6 +420,7 @@ describe("DeployAction", () => { vi.mocked(fs.readFileSync).mockReturnValue(contractContent); vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + txExecutionResultName: "FINISHED_WITH_RETURN", data: {contract_address: "0xdasdsadasdasdada"}, }); diff --git a/tests/actions/write.test.ts b/tests/actions/write.test.ts index e3820c7..530b038 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -34,7 +34,7 @@ describe("WriteAction", () => { test("calls writeContract successfully", async () => { const options = {args: [42, "Update"]}; const mockHash = "0xMockedTransactionHash"; - const mockReceipt = {status: "success"}; + const mockReceipt = {status: "success", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -52,6 +52,12 @@ describe("WriteAction", () => { value: 0n, }); expect(writeAction["log"]).toHaveBeenCalledWith("Write Transaction Hash:", mockHash); + expect(mockClient.waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: mockHash, + retries: 100, + interval: 5000, + fullTransaction: true, + }); expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( "Write operation successfully executed", mockReceipt, @@ -60,7 +66,7 @@ describe("WriteAction", () => { test("calls writeContract with fee options", async () => { const mockHash = "0xMockedTransactionHash"; - const mockReceipt = {status: "success"}; + const mockReceipt = {status: "success", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -116,10 +122,29 @@ describe("WriteAction", () => { ); }); + test("fails when write reaches consensus but execution fails", async () => { + const mockHash = "0xMockedTransactionHash"; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + status: "success", + txExecutionResultName: "FINISHED_WITH_ERROR", + }); + + await writeAction.write({contractAddress: "0xMockedContract", method: "updateData", args: [1]}); + + expect(writeAction["failSpinner"]).toHaveBeenCalledWith( + "Error during write operation", + expect.objectContaining({ + message: expect.stringContaining("execution failed with FINISHED_WITH_ERROR"), + }), + ); + }); + test("uses custom RPC URL for write operations", async () => { const options = {args: [42, "Update"], rpc: "https://custom-rpc-url.com"}; const mockHash = "0xMockedTransactionHash"; - const mockReceipt = {status: "success"}; + const mockReceipt = {status: "success", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); From 6fadcd7c9ef181042c823faeec1b7e7fb4d902b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 10 Jun 2026 13:14:28 +0100 Subject: [PATCH 2/6] fix(contracts)!: require consensus acceptance for success, not just the leader's execution result (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(contracts)!: require consensus acceptance for success, not just leader execution result - assertSuccessfulExecution now requires status ∈ {ACCEPTED, FINALIZED} via the SDK's isSuccessful in addition to FINISHED_WITH_RETURN: txExecutionResult is the LEADER's result and exists even on UNDETERMINED — previously an undetermined deploy printed 'deployed successfully' with an address that would never materialize - failure messages now name both the consensus outcome and the leader result ('decided as UNDETERMINED (no validator majority); leader execution result: FINISHED_WITH_RETURN') - map VoteType 3 (TIMEOUT) / 4 (NONDET_DISAGREE) with real diagnoses instead of 'did not expose an execution result' - deploy/write use waitUntil: 'decided' (new SDK API) and report the reached consensus status; explicit fee deposits are echoed before sending - callKey derivation deduplicated: import from genlayer-js (incl. new DEPLOY_CALL_KEY for deploy-targeted Mode-2 allocations) * build: pin genlayer-js to the v2 feature branch until the npm release npm 1.1.8 lacks the new fee APIs (isSuccessful, DEPLOY_CALL_KEY, waitUntil), so CI cannot build against it. github:genlayerlabs/genlayer-js#feat/ v06-fee-estimation-rework builds via the SDK's new prepare script (verified: fresh install produces dist + exports). Re-pin to the npm semver once genlayer-js v2 ships (release-sequencing item). * ci: run validate-code on pushes to the v0.40 line Push trigger was pinned to v0.39 — merges to v0.40-dev/v0.40 got no post-merge CI. * ci: make codecov upload non-blocking The OIDC upload fails on ubuntu/windows independent of this PR (#345 merged with identical failures); tests/build stay blocking. * build: re-pin genlayer-js to v2-dev (feature branch merged) --- .github/workflows/validate-code.yml | 7 +- package-lock.json | 20 +++- package.json | 2 +- src/commands/contracts/deploy.ts | 14 ++- src/commands/contracts/execution.ts | 80 +++++++++++++++- src/commands/contracts/fees.ts | 49 ++++------ src/commands/contracts/write.ts | 13 ++- tests/actions/deploy.test.ts | 125 ++++++++++++++++++++++++- tests/actions/estimateFees.test.ts | 5 +- tests/actions/write.test.ts | 140 ++++++++++++++++++++++++++-- 10 files changed, 394 insertions(+), 61 deletions(-) diff --git a/.github/workflows/validate-code.yml b/.github/workflows/validate-code.yml index cbf1655..0b4707d 100644 --- a/.github/workflows/validate-code.yml +++ b/.github/workflows/validate-code.yml @@ -9,6 +9,8 @@ on: push: branches: - v0.39 + - v0.40 + - v0.40-dev jobs: build-and-test: @@ -47,5 +49,8 @@ jobs: with: verbose: true token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true + # Codecov OIDC upload has been failing on ubuntu/windows since + # before this PR (#345 merged with the same failures) — keep the + # upload best-effort so coverage flakes don't block test-green PRs. + fail_ci_if_error: false directory: coverage diff --git a/package-lock.json b/package-lock.json index 02b49aa..434d9ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "dotenv": "^17.0.0", "ethers": "^6.13.4", "fs-extra": "^11.3.0", - "genlayer-js": "^1.1.8", + "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", "inquirer": "^12.0.0", "keytar": "^7.9.0", "node-fetch": "^3.0.0", @@ -325,6 +325,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -348,6 +349,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1646,6 +1648,7 @@ "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -2159,6 +2162,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2281,6 +2285,7 @@ "integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.42.0", "@typescript-eslint/types": "8.42.0", @@ -2917,6 +2922,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5582,8 +5588,7 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-1.1.8.tgz", - "integrity": "sha512-qlqh8oqR9Ad7FVbIdqIrHfsMPLLJ24ZRHUZ2LGMpw6DX5ySjrEWdV1X93bVIHO44cu9CLGdx8m2ubkPv78/RLg==", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#28e99fbc10ad962019e8314fb1b0d83d5a0e0938", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", @@ -6822,6 +6827,7 @@ "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -6850,6 +6856,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -8481,6 +8488,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@nodeutils/defaults-deep": "1.1.0", "@octokit/rest": "21.1.1", @@ -9515,6 +9523,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9812,6 +9821,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9910,6 +9920,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -10087,6 +10098,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.4.tgz", "integrity": "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -10200,6 +10212,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10651,6 +10664,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 3427604..64058f2 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "dotenv": "^17.0.0", "ethers": "^6.13.4", "fs-extra": "^11.3.0", - "genlayer-js": "^1.1.8", + "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", "inquirer": "^12.0.0", "keytar": "^7.9.0", "node-fetch": "^3.0.0", diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index 3dc0fff..9fad7e6 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -2,10 +2,10 @@ import fs from "fs"; import path from "path"; import {BaseAction} from "../../lib/actions/BaseAction"; import {pathToFileURL} from "url"; -import {TransactionStatus} from "genlayer-js/types"; +import {formatStakingAmount} from "genlayer-js"; import {buildSync} from "esbuild"; import {ContractFeeCliOptions, parseTransactionFees, parseValidUntil} from "./fees"; -import {assertSuccessfulExecution} from "./execution"; +import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution"; export interface DeployOptions extends ContractFeeCliOptions { contract?: string; @@ -133,12 +133,16 @@ export class DeployAction extends BaseAction { const leaderOnly = false; const deployParams: any = {code: contractCode, args: options.args, leaderOnly}; - const fees = parseTransactionFees(options); + const fees = parseTransactionFees(options, {deployTargeted: true}); const validUntil = parseValidUntil(options); if (fees) deployParams.fees = fees; if (validUntil !== undefined) deployParams.validUntil = validUntil; this.setSpinnerText("Starting contract deployment..."); + if (fees?.feeValue !== undefined) { + const feeValue = BigInt(fees.feeValue); + this.log(`Fee deposit: ${feeValue.toString()} wei (~${formatStakingAmount(feeValue)})`); + } this.log("Deployment Parameters:", deployParams); const hash = (await client.deployContract(deployParams)) as any; @@ -147,12 +151,13 @@ export class DeployAction extends BaseAction { hash, retries: 50, interval: 5000, - status: TransactionStatus.ACCEPTED, + waitUntil: "decided", fullTransaction: true, }); assertSuccessfulExecution("Deployment", hash, result); this.log("Deployment Receipt:", result); + this.log("Consensus Status:", transactionConsensusStatus(result)); const contractAddress = result.data?.contract_address ?? // localnet/studio @@ -161,6 +166,7 @@ export class DeployAction extends BaseAction { this.succeedSpinner("Contract deployed successfully.", { "Transaction Hash": hash, "Contract Address": contractAddress, + "Consensus Status": transactionConsensusStatus(result), }); } catch (error) { this.failSpinner("Error deploying contract", error); diff --git a/src/commands/contracts/execution.ts b/src/commands/contracts/execution.ts index 0778417..69d45a7 100644 --- a/src/commands/contracts/execution.ts +++ b/src/commands/contracts/execution.ts @@ -1,4 +1,5 @@ -import {ExecutionResult} from "genlayer-js/types"; +import {isSuccessful} from "genlayer-js"; +import {ExecutionResult, TransactionStatus, transactionsStatusNumberToName} from "genlayer-js/types"; function directField(value: unknown, names: string[]): unknown { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; @@ -16,6 +17,9 @@ function normalizeExecutionResult(value: unknown): ExecutionResult | undefined { if (normalized === ExecutionResult.FINISHED_WITH_RETURN) return ExecutionResult.FINISHED_WITH_RETURN; if (normalized === ExecutionResult.FINISHED_WITH_ERROR) return ExecutionResult.FINISHED_WITH_ERROR; if (normalized === ExecutionResult.NOT_VOTED) return ExecutionResult.NOT_VOTED; + if (normalized === ExecutionResult.TIMEOUT) return ExecutionResult.TIMEOUT; + if (normalized === ExecutionResult.NONDET_DISAGREE) return ExecutionResult.NONDET_DISAGREE; + if (normalized === "NONDET_DISAGREE" || normalized === "NONDET_DISAGREEMENT") return ExecutionResult.NONDET_DISAGREE; if (normalized === "SUCCESS") return ExecutionResult.FINISHED_WITH_RETURN; if (normalized === "ERROR" || normalized === "FAILURE") return ExecutionResult.FINISHED_WITH_ERROR; if (/^\d+$/.test(normalized)) return normalizeExecutionResult(Number(normalized)); @@ -25,11 +29,31 @@ function normalizeExecutionResult(value: unknown): ExecutionResult | undefined { if (numeric === 0) return ExecutionResult.NOT_VOTED; if (numeric === 1) return ExecutionResult.FINISHED_WITH_RETURN; if (numeric === 2) return ExecutionResult.FINISHED_WITH_ERROR; + if (numeric === 3) return ExecutionResult.TIMEOUT; + if (numeric === 4) return ExecutionResult.NONDET_DISAGREE; } return undefined; } -function transactionExecutionResult(receipt: unknown): ExecutionResult | undefined { +function normalizeConsensusStatus(value: unknown): TransactionStatus | undefined { + if (value == null) return undefined; + if (typeof value === "string") { + const normalized = value.toUpperCase(); + if (normalized in TransactionStatus) return normalized as TransactionStatus; + if (/^\d+$/.test(normalized)) return normalizeConsensusStatus(Number(normalized)); + } + if (typeof value === "number" || typeof value === "bigint") { + return transactionsStatusNumberToName[String(value) as keyof typeof transactionsStatusNumberToName]; + } + return undefined; +} + +function receiptData(receipt: unknown): { + record: Record; + data: unknown; + firstLeaderReceipt: unknown; + genvmResult: unknown; +} { const record = receipt && typeof receipt === "object" && !Array.isArray(receipt) ? receipt as Record : {}; @@ -40,7 +64,26 @@ function transactionExecutionResult(receipt: unknown): ExecutionResult | undefin const leaderReceipt = directField(consensusData, ["leader_receipt", "leaderReceipt"]); const firstLeaderReceipt = Array.isArray(leaderReceipt) ? leaderReceipt[0] : leaderReceipt; const genvmResult = directField(firstLeaderReceipt, ["genvm_result", "genvmResult"]); + return {record, data, firstLeaderReceipt, genvmResult}; +} + +export function transactionConsensusStatus(receipt: unknown): TransactionStatus | undefined { + const {record, data} = receiptData(receipt); + const candidates = [ + directField(record, ["statusName", "status_name"]), + directField(record, ["status"]), + directField(data, ["statusName", "status_name"]), + directField(data, ["status"]), + ]; + for (const candidate of candidates) { + const status = normalizeConsensusStatus(candidate); + if (status !== undefined) return status; + } + return undefined; +} +function transactionExecutionResult(receipt: unknown): ExecutionResult | undefined { + const {record, data, firstLeaderReceipt, genvmResult} = receiptData(receipt); const candidates = [ directField(record, ["txExecutionResultName", "tx_execution_result_name"]), directField(record, ["txExecutionResult", "tx_execution_result"]), @@ -57,19 +100,46 @@ function transactionExecutionResult(receipt: unknown): ExecutionResult | undefin return undefined; } +function consensusDiagnosis(status: TransactionStatus | undefined): string { + if (status === TransactionStatus.UNDETERMINED) return "UNDETERMINED (no validator majority)"; + if (status === TransactionStatus.CANCELED) return "CANCELED before execution"; + if (status === TransactionStatus.LEADER_TIMEOUT) return "LEADER_TIMEOUT"; + if (status === TransactionStatus.VALIDATORS_TIMEOUT) return "VALIDATORS_TIMEOUT"; + return status ?? "UNKNOWN"; +} + +function executionDiagnosis(result: ExecutionResult | undefined): string { + if (result === ExecutionResult.TIMEOUT) return "TIMEOUT (leader timed out during execution)"; + if (result === ExecutionResult.NONDET_DISAGREE) { + return "NONDET_DISAGREE (validators disagreed on non-deterministic output)"; + } + return result ?? "UNKNOWN"; +} + export function assertSuccessfulExecution( operation: string, hash: unknown, receipt: unknown, ): void { + const status = transactionConsensusStatus(receipt); const result = transactionExecutionResult(receipt); - if (result === ExecutionResult.FINISHED_WITH_RETURN) return; + if (isSuccessful(receipt as any) || isSuccessful({ + ...(receipt && typeof receipt === "object" && !Array.isArray(receipt) ? receipt as Record : {}), + statusName: status, + txExecutionResultName: result, + } as any)) { + return; + } + + const decidedAs = consensusDiagnosis(status); if (result === undefined) { throw new Error( - `${operation} ${String(hash)} reached consensus status but did not expose an execution result.`, + `${operation} ${String(hash)} transaction was decided as ${decidedAs}; leader execution result: UNKNOWN.`, ); } - throw new Error(`${operation} ${String(hash)} execution failed with ${result}.`); + throw new Error( + `${operation} ${String(hash)} transaction was decided as ${decidedAs}; leader execution result: ${executionDiagnosis(result)}.`, + ); } diff --git a/src/commands/contracts/fees.ts b/src/commands/contracts/fees.ts index 4e2a839..acd914d 100644 --- a/src/commands/contracts/fees.ts +++ b/src/commands/contracts/fees.ts @@ -1,4 +1,8 @@ -import {hexToBytes, keccak256, toHex, type Hex} from "viem"; +import { + DEPLOY_CALL_KEY, + deriveExternalMessageCallKey, + deriveInternalMessageCallKey, +} from "genlayer-js"; export interface ContractFeeCliOptions { fees?: string; @@ -48,34 +52,6 @@ const parseBigNumberishOption = (value: string | undefined, optionName: string): return trimmed; }; -const CALL_KEY_UNNAMED = "0x0000000000000000000000000000000000000000000000000000000000000000" as const; - -const bytesToPaddedCallKey = (bytes: Uint8Array): Hex => { - if (bytes.length > 32) { - throw new Error("call key source bytes must be 32 bytes or fewer."); - } - return `0x${toHex(bytes).slice(2).padEnd(64, "0")}` as Hex; -}; - -const deriveInternalMessageCallKey = (methodName = ""): Hex => { - const methodBytes = new TextEncoder().encode(methodName); - if (methodBytes.length < 32) { - return bytesToPaddedCallKey(methodBytes); - } - - const hashed = keccak256(methodBytes); - const lastByte = Number.parseInt(hashed.slice(-2), 16) | 1; - return `${hashed.slice(0, -2)}${lastByte.toString(16).padStart(2, "0")}` as Hex; -}; - -const deriveExternalMessageCallKey = (selectorOrCalldata: Hex): Hex => { - const bytes = hexToBytes(selectorOrCalldata); - if (bytes.length < 4) { - return CALL_KEY_UNNAMED; - } - return bytesToPaddedCallKey(bytes.slice(0, 4)); -}; - const normalizeMessageType = (messageType: unknown, index: number): 0 | 1 | undefined => { if (messageType === undefined) { return undefined; @@ -178,7 +154,7 @@ const normalizeMessageAllocationCallKey = ( return normalized; }; -const normalizeMessageTypes = (fees: Record): Record => { +const normalizeMessageTypes = (fees: Record, deployTargeted = false): Record => { if (!Array.isArray(fees.messageAllocations)) { return fees; } @@ -191,15 +167,22 @@ const normalizeMessageTypes = (fees: Record): Record = } const messageType = normalizeMessageType(allocation.messageType, index); - return normalizeMessageAllocationCallKey({ + const normalized = normalizeMessageAllocationCallKey({ ...allocation, ...(messageType === undefined ? {} : {messageType}), }, messageType, index); + if (deployTargeted && normalized.callKey === undefined) { + normalized.callKey = DEPLOY_CALL_KEY; + } + return normalized; }), }; }; -export const parseTransactionFees = (options: ContractFeeCliOptions): Record | undefined => { +export const parseTransactionFees = ( + options: ContractFeeCliOptions, + config: {deployTargeted?: boolean} = {}, +): Record | undefined => { const feeValue = parseBigNumberishOption(options.feeValue, "--fee-value"); let fees = options.fees ? parseJsonObject(options.fees, "--fees") : undefined; @@ -207,7 +190,7 @@ export const parseTransactionFees = (options: ContractFeeCliOptions): Record { vi.mocked(createClient).mockReturnValue(mockClient as any); vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any); + vi.mocked(formatStakingAmount).mockImplementation((value: bigint) => `${value.toString()} GEN`); + vi.mocked(isSuccessful).mockImplementation((receipt: any) => { + const statusName = receipt.statusName ?? receipt.status; + const executionResultName = receipt.txExecutionResultName ?? ( + receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined + ); + return ( + (statusName === "ACCEPTED" || statusName === "FINALIZED") && + executionResultName === "FINISHED_WITH_RETURN" + ); + }); deployer = new DeployAction(); vi.spyOn(deployer as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey}); vi.spyOn(deployer as any, "getConfig").mockReturnValue({}); @@ -81,6 +92,7 @@ describe("DeployAction", () => { 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"}, }); @@ -97,7 +109,7 @@ describe("DeployAction", () => { hash: "mocked_tx_hash", retries: 50, interval: 5000, - status: "ACCEPTED", + waitUntil: "decided", fullTransaction: true, }); expect(mockClient.deployContract).toHaveReturnedWith(Promise.resolve("mocked_tx_hash")); @@ -127,6 +139,7 @@ describe("DeployAction", () => { 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"}, }); @@ -145,6 +158,7 @@ describe("DeployAction", () => { messageAllocations: [{ messageType: 1, recipient: "0x0000000000000000000000000000000000000001", + callKey: "0x0000000000000000000000000000000000000000000000000000000000000001", budget: "5", }], feeValue: "123", @@ -163,6 +177,7 @@ describe("DeployAction", () => { 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_ERROR", data: {contract_address: "0xdasdsadasdasdada"}, }); @@ -172,11 +187,114 @@ describe("DeployAction", () => { expect(deployer["failSpinner"]).toHaveBeenCalledWith( "Error deploying contract", expect.objectContaining({ - message: expect.stringContaining("execution failed with FINISHED_WITH_ERROR"), + message: expect.stringContaining("leader execution result: FINISHED_WITH_ERROR"), + }), + ); + }); + + test("fails when deployment is undetermined despite leader return", async () => { + const options: DeployOptions = { + contract: "/mocked/contract/path", + args: [1, 2, 3], + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "UNDETERMINED", + txExecutionResultName: "FINISHED_WITH_RETURN", + }); + + await deployer.deploy(options); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining("UNDETERMINED"), + }), + ); + }); + + test("diagnoses leader execution timeout", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResult: 3, + }); + + await deployer.deploy({contract: "/mocked/contract/path"}); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining("leader timed out during execution"), + }), + ); + }); + + test("diagnoses non-deterministic disagreement", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResult: 4, + }); + + await deployer.deploy({contract: "/mocked/contract/path"}); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining("validators disagreed on non-deterministic output"), + }), + ); + }); + + test("fails when deployment is canceled", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "CANCELED", + txExecutionResultName: "NOT_VOTED", + }); + + await deployer.deploy({contract: "/mocked/contract/path"}); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining("CANCELED before execution"), }), ); }); + test("accepts studio-shaped successful receipt", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + data: { + contract_address: "0xdasdsadasdasdada", + consensus_data: { + leader_receipt: [{execution_result: "SUCCESS"}], + }, + }, + }); + + await deployer.deploy({contract: "/mocked/contract/path"}); + + expect(deployer["succeedSpinner"]).toHaveBeenCalledWith( + "Contract deployed successfully.", + expect.objectContaining({"Consensus Status": "ACCEPTED"}), + ); + }); + test("throws error for missing contract", async () => { const options: DeployOptions = {}; @@ -420,6 +538,7 @@ describe("DeployAction", () => { 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"}, }); diff --git a/tests/actions/estimateFees.test.ts b/tests/actions/estimateFees.test.ts index c9634b4..cf07246 100644 --- a/tests/actions/estimateFees.test.ts +++ b/tests/actions/estimateFees.test.ts @@ -1,5 +1,5 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; -import {createClient, createAccount} from "genlayer-js"; +import {createClient, createAccount, deriveInternalMessageCallKey} from "genlayer-js"; import {EstimateFeesAction} from "../../src/commands/contracts/estimateFees"; vi.mock("genlayer-js"); @@ -20,6 +20,9 @@ describe("EstimateFeesAction", () => { vi.clearAllMocks(); vi.mocked(createClient).mockReturnValue(mockClient as any); vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any); + vi.mocked(deriveInternalMessageCallKey).mockImplementation((methodName = "") => ( + `0x${Buffer.from(methodName, "utf8").toString("hex").padEnd(64, "0")}` as `0x${string}` + )); action = new EstimateFeesAction(); vi.spyOn(action as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey}); vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); diff --git a/tests/actions/write.test.ts b/tests/actions/write.test.ts index 530b038..1e48352 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -1,5 +1,11 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; -import {createClient, createAccount} from "genlayer-js"; +import { + createClient, + createAccount, + isSuccessful, + formatStakingAmount, + deriveExternalMessageCallKey, +} from "genlayer-js"; import {WriteAction} from "../../src/commands/contracts/write"; vi.mock("genlayer-js"); @@ -18,6 +24,26 @@ describe("WriteAction", () => { vi.clearAllMocks(); vi.mocked(createClient).mockReturnValue(mockClient as any); vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any); + vi.mocked(formatStakingAmount).mockImplementation((value: bigint) => `${value.toString()} GEN`); + vi.mocked(deriveExternalMessageCallKey).mockImplementation( + (selectorOrCalldata: `0x${string}` | Uint8Array = "0x") => { + const hex = typeof selectorOrCalldata === "string" + ? selectorOrCalldata.slice(2) + : Buffer.from(selectorOrCalldata).toString("hex"); + if (hex.length < 8) return "0x0000000000000000000000000000000000000000000000000000000000000000"; + return `0x${hex.slice(0, 8).padEnd(64, "0")}`; + }, + ); + vi.mocked(isSuccessful).mockImplementation((receipt: any) => { + const statusName = receipt.statusName ?? receipt.status; + const executionResultName = receipt.txExecutionResultName ?? ( + receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined + ); + return ( + (statusName === "ACCEPTED" || statusName === "FINALIZED") && + executionResultName === "FINISHED_WITH_RETURN" + ); + }); writeAction = new WriteAction(); vi.spyOn(writeAction as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey}); @@ -34,7 +60,7 @@ describe("WriteAction", () => { test("calls writeContract successfully", async () => { const options = {args: [42, "Update"]}; const mockHash = "0xMockedTransactionHash"; - const mockReceipt = {status: "success", txExecutionResultName: "FINISHED_WITH_RETURN"}; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -56,17 +82,18 @@ describe("WriteAction", () => { hash: mockHash, retries: 100, interval: 5000, + waitUntil: "decided", fullTransaction: true, }); expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( "Write operation successfully executed", - mockReceipt, + {...mockReceipt, consensusStatus: "ACCEPTED"}, ); }); test("calls writeContract with fee options", async () => { const mockHash = "0xMockedTransactionHash"; - const mockReceipt = {status: "success", txExecutionResultName: "FINISHED_WITH_RETURN"}; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -127,7 +154,7 @@ describe("WriteAction", () => { vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ - status: "success", + statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_ERROR", }); @@ -136,15 +163,112 @@ describe("WriteAction", () => { expect(writeAction["failSpinner"]).toHaveBeenCalledWith( "Error during write operation", expect.objectContaining({ - message: expect.stringContaining("execution failed with FINISHED_WITH_ERROR"), + message: expect.stringContaining("leader execution result: FINISHED_WITH_ERROR"), + }), + ); + }); + + test("fails when write is undetermined despite leader return", async () => { + const mockHash = "0xMockedTransactionHash"; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "UNDETERMINED", + txExecutionResultName: "FINISHED_WITH_RETURN", + }); + + await writeAction.write({contractAddress: "0xMockedContract", method: "updateData", args: [1]}); + + expect(writeAction["failSpinner"]).toHaveBeenCalledWith( + "Error during write operation", + expect.objectContaining({ + message: expect.stringContaining("UNDETERMINED"), + }), + ); + }); + + test("diagnoses leader execution timeout", async () => { + const mockHash = "0xMockedTransactionHash"; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResult: 3, + }); + + await writeAction.write({contractAddress: "0xMockedContract", method: "updateData", args: [1]}); + + expect(writeAction["failSpinner"]).toHaveBeenCalledWith( + "Error during write operation", + expect.objectContaining({ + message: expect.stringContaining("leader timed out during execution"), + }), + ); + }); + + test("diagnoses non-deterministic disagreement", async () => { + const mockHash = "0xMockedTransactionHash"; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResult: 4, + }); + + await writeAction.write({contractAddress: "0xMockedContract", method: "updateData", args: [1]}); + + expect(writeAction["failSpinner"]).toHaveBeenCalledWith( + "Error during write operation", + expect.objectContaining({ + message: expect.stringContaining("validators disagreed on non-deterministic output"), }), ); }); + test("fails when write is canceled", async () => { + const mockHash = "0xMockedTransactionHash"; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "CANCELED", + txExecutionResultName: "NOT_VOTED", + }); + + await writeAction.write({contractAddress: "0xMockedContract", method: "updateData", args: [1]}); + + expect(writeAction["failSpinner"]).toHaveBeenCalledWith( + "Error during write operation", + expect.objectContaining({ + message: expect.stringContaining("CANCELED before execution"), + }), + ); + }); + + test("accepts studio-shaped successful receipt", async () => { + const mockHash = "0xMockedTransactionHash"; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + data: { + consensus_data: { + leader_receipt: [{execution_result: "SUCCESS"}], + }, + }, + }); + + await writeAction.write({contractAddress: "0xMockedContract", method: "updateData", args: [1]}); + + expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( + "Write operation successfully executed", + expect.objectContaining({consensusStatus: "ACCEPTED"}), + ); + }); + test("uses custom RPC URL for write operations", async () => { const options = {args: [42, "Update"], rpc: "https://custom-rpc-url.com"}; const mockHash = "0xMockedTransactionHash"; - const mockReceipt = {status: "success", txExecutionResultName: "FINISHED_WITH_RETURN"}; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -168,7 +292,7 @@ describe("WriteAction", () => { }); expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( "Write operation successfully executed", - mockReceipt, + {...mockReceipt, consensusStatus: "ACCEPTED"}, ); }); }); From 21002551c254d5f906714a91ec1fa182add4fdb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 10 Jun 2026 15:46:07 +0100 Subject: [PATCH 3/6] ci: keep main forwarded to active dev branch (#348) --- .github/scripts/validate-branch-policy.sh | 107 ++++++++++++++++++++++ .github/workflows/branch-policy.yml | 24 +++++ .github/workflows/fast-forward-main.yaml | 57 ++++++++++++ .github/workflows/retarget-main-prs.yaml | 53 +++++++++++ support/ci/ACTIVE_DEV_BRANCH | 1 + 5 files changed, 242 insertions(+) create mode 100755 .github/scripts/validate-branch-policy.sh create mode 100644 .github/workflows/branch-policy.yml create mode 100644 .github/workflows/fast-forward-main.yaml create mode 100644 .github/workflows/retarget-main-prs.yaml create mode 100644 support/ci/ACTIVE_DEV_BRANCH diff --git a/.github/scripts/validate-branch-policy.sh b/.github/scripts/validate-branch-policy.sh new file mode 100755 index 0000000..0fb7ee2 --- /dev/null +++ b/.github/scripts/validate-branch-policy.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +failed=0 + +error() { + echo "::error::$*" + failed=1 +} + +warning() { + echo "::warning::$*" +} + +active_branch_file="support/ci/ACTIVE_DEV_BRANCH" +if [[ ! -f "${active_branch_file}" ]]; then + error "${active_branch_file} is required." + active_branch="" +else + active_branch="$(tr -d '[:space:]' < "${active_branch_file}")" +fi + +if [[ -z "${active_branch}" ]]; then + error "${active_branch_file} must not be empty." +elif [[ "${active_branch}" == "main" ]]; then + error "${active_branch_file} must point to a dev branch, not main." +elif [[ "${active_branch}" != *-dev ]]; then + warning "${active_branch_file} should normally point to a -dev branch; got ${active_branch}." +fi + +release_branch="${active_branch%-dev}" +default_branch="${GITHUB_DEFAULT_BRANCH:-}" +event_name="${GITHUB_EVENT_NAME:-local}" +base_ref="${GITHUB_BASE_REF:-}" +head_ref="${GITHUB_HEAD_REF:-}" +ref_name="${GITHUB_REF_NAME:-}" +actor="${GITHUB_ACTOR:-}" + +if [[ -n "${default_branch}" && "${default_branch}" != "main" ]]; then + warning "Repository default branch should be main after branch-policy rollout; currently ${default_branch}." +fi + +if [[ -n "${base_ref}" && "${base_ref}" == "main" ]]; then + warning "PR targets main; retarget-main-prs should move it to ${active_branch}." +fi + +if [[ -n "${base_ref}" && -n "${active_branch}" ]]; then + if [[ "${base_ref}" == "${release_branch}" && "${head_ref}" != "${active_branch}" && "${ALLOW_DIRECT_RELEASE_PR:-false}" != "true" ]]; then + error "PRs into ${release_branch} must come from ${active_branch}. Merge feature work into ${active_branch}, then promote ${active_branch} -> ${release_branch}." + fi +fi + +if [[ "${event_name}" == "push" && "${ref_name}" == "main" ]]; then + case "${actor}" in + github-actions[bot]|ci-core-e2e-runner[bot]) + ;; + *) + error "main should only move by automation from ${active_branch}; direct push actor was ${actor:-unknown}." + ;; + esac +fi + +if [[ ! -f ".github/workflows/fast-forward-main.yaml" ]]; then + error ".github/workflows/fast-forward-main.yaml is required." +fi + +if [[ ! -f ".github/workflows/retarget-main-prs.yaml" ]]; then + error ".github/workflows/retarget-main-prs.yaml is required." +fi + +if [[ -f ".github/workflows/release-from-main.yml" ]]; then + error ".github/workflows/release-from-main.yml is forbidden. Releases must be tag/version-branch driven." +fi + +if [[ -f "release.config.js" ]]; then + error "release.config.js is forbidden in versioned tooling branches; semantic-release-on-main must not be restored." +fi + +if [[ -f ".github/workflows/release-from-tag.yml" ]]; then + if ! grep -Fq 'v*.*.*' .github/workflows/release-from-tag.yml; then + error "release-from-tag.yml must trigger only from version tags matching v*.*.*." + fi + if ! grep -Fq 'refs/remotes/origin/${version_branch}' .github/workflows/release-from-tag.yml || \ + ! grep -Fq 'tag_commit' .github/workflows/release-from-tag.yml || \ + ! grep -Fq 'branch_head' .github/workflows/release-from-tag.yml; then + error "release-from-tag.yml must verify the tag commit is the current matching version branch head." + fi +fi + +if [[ -f ".github/workflows/manual-docker-release.yml" ]]; then + if ! grep -Fq 'expected_branch=' .github/workflows/manual-docker-release.yml; then + error "manual-docker-release.yml must derive and enforce the expected version branch from the tag." + fi + if ! grep -Fq './.github/workflows/release-from-tag.yml' .github/workflows/manual-docker-release.yml; then + error "manual-docker-release.yml must delegate image promotion to release-from-tag.yml." + fi +fi + +if [[ "${failed}" -ne 0 ]]; then + exit 1 +fi + +if [[ -n "${base_ref}" ]]; then + echo "Branch policy ok for PR ${head_ref} -> ${base_ref}; active dev branch is ${active_branch}." +else + echo "Branch policy ok for ${event_name} on ${ref_name:-detached ref}; active dev branch is ${active_branch}." +fi diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml new file mode 100644 index 0000000..7759870 --- /dev/null +++ b/.github/workflows/branch-policy.yml @@ -0,0 +1,24 @@ +name: Branch Policy + +on: + pull_request: + types: [opened, synchronize, reopened, edited, ready_for_review] + push: + branches: + - "**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + branch-policy: + name: Validate branch policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate branch policy + env: + GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: ./.github/scripts/validate-branch-policy.sh diff --git a/.github/workflows/fast-forward-main.yaml b/.github/workflows/fast-forward-main.yaml new file mode 100644 index 0000000..688e997 --- /dev/null +++ b/.github/workflows/fast-forward-main.yaml @@ -0,0 +1,57 @@ +name: Fast-forward main + +# main is the static/default branch for GitHub UX and tools that assume a +# stable default branch. It is not the integration target. On each push to the +# configured active dev branch, fast-forward main to that commit. + +on: + push: + branches: ["**"] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: fast-forward-main-${{ github.repository }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + fast-forward: + if: github.ref_type == 'branch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fast-forward main to active dev branch + run: | + set -euo pipefail + + active_branch="$(tr -d '[:space:]' < support/ci/ACTIVE_DEV_BRANCH)" + if [[ -z "${active_branch}" || "${active_branch}" == "main" ]]; then + echo "::error::support/ci/ACTIVE_DEV_BRANCH must name a non-main dev branch" + exit 1 + fi + + if [[ "${GITHUB_REF_NAME}" != "${active_branch}" ]]; then + echo "Push was to ${GITHUB_REF_NAME}; active dev branch is ${active_branch}. Nothing to do." + exit 0 + fi + + if git ls-remote --exit-code --heads origin main >/dev/null 2>&1; then + git fetch origin main + if ! git merge-base --is-ancestor origin/main HEAD; then + echo "::error::main has diverged from ${active_branch}; refusing non-fast-forward update" + exit 1 + fi + else + echo "main does not exist yet; creating it at ${GITHUB_SHA}." + fi + + git push origin "HEAD:refs/heads/main" diff --git a/.github/workflows/retarget-main-prs.yaml b/.github/workflows/retarget-main-prs.yaml new file mode 100644 index 0000000..37a066f --- /dev/null +++ b/.github/workflows/retarget-main-prs.yaml @@ -0,0 +1,53 @@ +name: Retarget main PRs + +# main is a static/default alias of the active dev branch. Contributions should +# target the active dev branch directly; PRs opened against main are retargeted +# automatically so required checks and release-train rules run in the right +# branch context. +# +# pull_request_target is used for the write-scoped token. This workflow never +# checks out or executes PR head code; it reads only trusted base-branch files. + +on: + pull_request_target: + types: [opened, reopened, synchronize, edited, ready_for_review] + +permissions: + contents: read + pull-requests: write + issues: write + +defaults: + run: + shell: bash + +jobs: + retarget: + if: github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + + - name: Retarget PR to active dev branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + active_branch="$(tr -d '[:space:]' < support/ci/ACTIVE_DEV_BRANCH)" + if [[ -z "${active_branch}" || "${active_branch}" == "main" ]]; then + echo "::error::support/ci/ACTIVE_DEV_BRANCH must name a non-main dev branch" + exit 1 + fi + + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --base "${active_branch}" + + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body "$(cat < Date: Thu, 11 Jun 2026 15:55:37 +0100 Subject: [PATCH 4/6] fix(system): propagate command-check and version parse fixes to v0.40-dev (#350) Propagates #349 to v0.40-dev. --- src/lib/clients/system.ts | 36 +++++++++++++++++++----------------- tests/libs/system.test.ts | 39 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/lib/clients/system.ts b/src/lib/clients/system.ts index 30cb4eb..1fcc0b7 100644 --- a/src/lib/clients/system.ts +++ b/src/lib/clients/system.ts @@ -9,9 +9,7 @@ export async function checkCommand(command: string, toolName: string): Promise { } export async function getVersion(toolName: string): Promise { + let toolResponse: {stdout?: string; stderr?: string}; + try { - const toolResponse = await util.promisify(exec)(`${toolName} --version`); + toolResponse = await util.promisify(exec)(`${toolName} --version`); + } catch (error) { + throw new Error(`Error getting ${toolName} version.`); + } - if (toolResponse.stderr) { - throw new Error(toolResponse.stderr); - } + if (toolResponse.stderr) { + throw new Error(`Error getting ${toolName} version.`); + } - try { - const versionMatch = toolResponse.stdout.match(/(\d+\.\d+\.\d+)/); - if (versionMatch) { - return versionMatch[1]; - } - } catch (err) { - throw new Error(`Could not parse ${toolName} version.`); - } - } catch (error) { + if (toolResponse.stdout == null) { throw new Error(`Error getting ${toolName} version.`); } - return ""; + const versionMatch = toolResponse.stdout.match(/(\d+\.\d+\.\d+)/); + if (versionMatch) { + return versionMatch[1]; + } + + throw new Error( + `Could not parse ${toolName} version from output: "${toolResponse.stdout}". Expected format: X.Y.Z` + ); } diff --git a/tests/libs/system.test.ts b/tests/libs/system.test.ts index 5bcd80a..be6c12c 100644 --- a/tests/libs/system.test.ts +++ b/tests/libs/system.test.ts @@ -70,13 +70,15 @@ describe("System Functions - Error Paths", () => { await expect(getVersion(toolName)).rejects.toThrow(`Error getting ${toolName} version.`); }); - test("getVersion returns '' if stdout is empty", async () => { + test("getVersion throws when stdout does not match version pattern", async () => { vi.mocked(util.promisify).mockReturnValueOnce(() => Promise.resolve({ stdout: "", stderr: "" })); - const result = await getVersion('git'); - expect(result).toBe(""); + const toolName = "git"; + await expect(getVersion(toolName)).rejects.toThrow( + `Could not parse ${toolName} version from output` + ); }); test("getVersion throw error if stdout undefined", async () => { @@ -87,6 +89,17 @@ describe("System Functions - Error Paths", () => { await expect(getVersion(toolName)).rejects.toThrow(`Error getting ${toolName} version.`); }); + test("getVersion throws when stdout has non-matching version format (e.g. major-only)", async () => { + vi.mocked(util.promisify).mockReturnValueOnce(() => Promise.resolve({ + stdout: "Docker version 25", + stderr: "" + })); + const toolName = "docker"; + await expect(getVersion(toolName)).rejects.toThrow( + `Could not parse ${toolName} version from output` + ); + }); + test("checkCommand returns false if the command does not exist", async () => { vi.mocked(util.promisify).mockReturnValueOnce(() => Promise.reject({ stdout: "", @@ -96,6 +109,26 @@ describe("System Functions - Error Paths", () => { await expect(checkCommand(`${toolName} --version`, toolName)).rejects.toThrow(new MissingRequirementError(toolName)); }); + test("checkCommand throws MissingRequirementError when binary is not installed (ENOENT)", async () => { + vi.mocked(util.promisify).mockReturnValueOnce(() => Promise.reject({ + code: 'ENOENT', + stderr: '', + message: 'spawn ENOENT' + })); + const toolName = 'docker'; + await expect(checkCommand(`${toolName} --version`, toolName)).rejects.toThrow(new MissingRequirementError(toolName)); + }); + + test("checkCommand throws MissingRequirementError when command exits without stderr", async () => { + vi.mocked(util.promisify).mockReturnValueOnce(() => Promise.reject({ + code: 127, + stderr: '', + message: 'command failed' + })); + const toolName = 'docker'; + await expect(checkCommand(`${toolName} --version`, toolName)).rejects.toThrow(new MissingRequirementError(toolName)); + }); + test("executeCommand throws an error if the command fails", async () => { vi.mocked(util.promisify).mockReturnValueOnce(() => Promise.reject(new Error("Execution failed"))); await expect(executeCommand({ From 8c898c39f4f8d178dd980965025af445deda5eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 11 Jun 2026 17:34:38 +0100 Subject: [PATCH 5/6] docs: add branching guide (#353) * docs: add branching guide * ci: harden testnet smoke timeout --- .github/workflows/smoke.yml | 4 ++- CONTRIBUTING.md | 20 ++++--------- docs/BRANCHING.md | 58 +++++++++++++++++++++++++++++++++++++ tests/smoke.test.ts | 21 +++++++++----- 4 files changed, 80 insertions(+), 23 deletions(-) create mode 100644 docs/BRANCHING.md diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 13650c2..ec29f22 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -10,7 +10,7 @@ jobs: smoke: name: Testnet Smoke Tests runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 continue-on-error: true steps: @@ -41,3 +41,5 @@ jobs: - name: Run smoke tests run: npm run test:smoke + env: + CLI_SMOKE_TIMEOUT_MS: 240000 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e52bf0a..5995aa8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,19 +33,11 @@ Have ideas for new features or use cases? We're eager to hear them! But first: ## Branch model -This repo uses a branch-per-major release model. There is no `main`. - -- **`v0.39`** — current stable major (semver-zero, so 0.39 IS the major; 0.40 would be a major bump that gets its own branch). PRs for bug fixes / non-breaking features target this branch. -- **`v-dev`** — when next-major work is in progress, this branch is open for breaking changes. PRs introducing them target this branch. -- **Older majors** stay for back-ports. Default branch on github.com is whichever major is current stable. - -When you fork or clone, the default branch is `v0.39` today. If you have a `main` branch from a previous checkout, delete it locally: - -```sh -git checkout v0.39 -git branch -D main -git remote prune origin -``` +See [docs/BRANCHING.md](docs/BRANCHING.md) for the current release-train model. +In short: independently releasable work may target the stable branch directly; +multi-feature or cross-repo train work uses the active `*-dev` integration +branch and is promoted to the matching stable branch when ready. `main` is only +the default/static GitHub branch. The previous `staging` branch (beta channel) has been retired. Pre-releases now go through the same release script with an explicit version (`scripts/release.sh 0.39.2-beta.0`). @@ -138,4 +130,4 @@ Connect with the GenLayer community to discuss, collaborate, and share insights: - **[Discord Channel](https://discord.gg/8Jm4v89VAu)**: Our primary hub for discussions, support, and announcements. - **[Telegram Group](https://t.me/genlayer)**: For more informal chats and quick updates. -Your continuous feedback drives better product development. Please engage with us regularly to test, discuss, and improve the GenLayer CLI. \ No newline at end of file +Your continuous feedback drives better product development. Please engage with us regularly to test, discuss, and improve the GenLayer CLI. diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md new file mode 100644 index 0000000..5b8d3d6 --- /dev/null +++ b/docs/BRANCHING.md @@ -0,0 +1,58 @@ +# Branching and Release Trains + +This repo follows the GenLayer release-train model. + +## Current Train + +- Current stable branch: `v0.39` +- Active integration branch: `v0.40-dev` +- Next stable target: `v0.40` +- `main`: default/static branch alias for the active integration branch + +## Stable Branches + +Stable branches are long-lived release lines. For semver-zero packages, each +minor line is treated as the release line, for example `v0.39` or `v0.40`. + +PRs may target a stable branch directly when the merged result should be +releasable immediately. This is appropriate for bug fixes, small non-breaking +features, isolated release fixes, or a breaking change that is intentionally +shipping as the next version by itself. + +Stable branches must remain releasable. PRs into stable branches are expected to +pass the required cross-repo `E2E Tests` gate before merge. + +## Integration Branches + +Integration branches are optional. Use one when multiple changes need to +accumulate before release, especially for cross-repo work, dependent features, +breaking changes that must ship together, or a train that needs advisory E2E +while still expected to be red. + +Integration branches are named after the target stable branch plus `-dev`, for +example `v0.40-dev`. Feature PRs for that train target the integration branch. + +PRs into integration branches may run `E2E Tests` as advisory checks. They are +not the release gate. + +## Promotion and Release + +When an integration train is ready, open a promotion PR from the integration +branch to the matching stable branch, for example `v0.40-dev` to `v0.40`. + +That promotion PR is the release-readiness gate and must pass required +cross-repo `E2E Tests`. The actual package release is cut from the stable branch +using a version tag after the stable branch is ready. + +## `main` + +`main` exists for GitHub UX and tools that require a stable default branch. It is +not a release branch and it is not the integration target. + +This repo keeps `main` forwarded to the active integration branch using +automation. PRs opened against `main` are automatically retargeted to the branch +listed in `support/ci/ACTIVE_DEV_BRANCH`. + +When changing the active integration branch, update +`support/ci/ACTIVE_DEV_BRANCH`, the repo docs, and the corresponding +`genlayer-e2e` release-train matrix in the same change set. diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 5680887..7c34ed3 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -1,16 +1,19 @@ import {describe, it, expect, beforeAll} from "vitest"; -import {execSync} from "child_process"; +import {execFile} from "child_process"; +import {promisify} from "util"; import path from "path"; import {createClient, parseStakingAmount, formatStakingAmount} from "genlayer-js"; import {testnetAsimov, testnetBradbury} from "genlayer-js/chains"; import type {Address, GenLayerChain} from "genlayer-js/types"; const CLI = path.resolve(__dirname, "../dist/index.js"); +const execFileAsync = promisify(execFile); // Testnet validator-list fetches ALL validators + per-validator detail in -// batches; on bradbury/asimov that routinely passes 30s. 90s gives headroom -// without hiding real hangs. +// batches; on bradbury/asimov that routinely passes 30s. Keep RPC calls capped +// at 90s, but give the full CLI smoke path extra room for live testnet slowness. const TIMEOUT = 90_000; +const CLI_TIMEOUT = Number(process.env.CLI_SMOKE_TIMEOUT_MS ?? 180_000); const testnets: {name: string; chain: GenLayerChain}[] = [ {name: "Asimov", chain: testnetAsimov}, @@ -127,14 +130,16 @@ describe(`Testnet ${name} - CLI Staking Smoke Tests`, () => { } }, TIMEOUT); - it("CLI: genlayer staking validators lists validators", () => { - const output = execSync( - `node ${CLI} staking validators --network ${name === "Asimov" ? "testnet-asimov" : "testnet-bradbury"}`, - {encoding: "utf-8", timeout: TIMEOUT}, + it("CLI: genlayer staking validators lists validators", async () => { + const {stdout, stderr} = await execFileAsync( + "node", + [CLI, "staking", "validators", "--network", name === "Asimov" ? "testnet-asimov" : "testnet-bradbury"], + {encoding: "utf-8", timeout: CLI_TIMEOUT}, ); + const output = `${stdout}${stderr}`; expect(output).toContain("active"); expect(output).toMatch(/Total: \d+ validators/); - }, TIMEOUT); + }, CLI_TIMEOUT + 10_000); it("parseStakingAmount and formatStakingAmount round-trip", () => { const parsed = parseStakingAmount("1.5gen"); From 6edfcfa6d1ad2fcbf761c1ed9f3a194d31243624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Tue, 16 Jun 2026 10:45:55 +0100 Subject: [PATCH 6/6] feat: support fee profiles in contract commands (#355) * feat: support fee profiles in contract commands * test: make fee profile deploy test portable --- README.md | 59 +++++- docs/api-references/contracts/deploy.mdx | 16 +- docs/api-references/contracts/write.mdx | 14 +- docs/api-references/estimate-fees.mdx | 28 +++ docs/api-references/index.mdx | 1 + src/commands/contracts/deploy.ts | 13 +- src/commands/contracts/estimateFees.ts | 39 ++-- src/commands/contracts/fees.ts | 247 +++++++++++++++++++---- src/commands/contracts/index.ts | 52 ++--- src/commands/contracts/write.ts | 27 ++- tests/actions/deploy.test.ts | 101 +++++++-- tests/actions/estimateFees.test.ts | 176 ++++++++++++++-- tests/actions/write.test.ts | 128 +++++++++--- tests/commands/deploy.test.ts | 48 +++-- tests/commands/estimateFees.test.ts | 42 ++-- tests/commands/write.test.ts | 25 +++ 16 files changed, 820 insertions(+), 196 deletions(-) create mode 100644 docs/api-references/estimate-fees.mdx diff --git a/README.md b/README.md index d33dc46..61f32d6 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,9 @@ OPTIONS (deploy): --contract (Optional) Path to the intelligent contract to deploy --rpc RPC URL for the network --fees Transaction fee options JSON passed to genlayer-js + --fee-profile Fee profile generated by gltest --fee-profile + --fee-preset Fee profile appeal posture: low, standard, or high + --appeal-rounds Override fee profile appeal rounds --fee-value Explicit fee deposit value --valid-until Unix timestamp after which the transaction is invalid --args Contract arguments (see Argument Types below) @@ -187,6 +190,9 @@ OPTIONS (call): OPTIONS (write): --rpc RPC URL for the network --fees Transaction fee options JSON passed to genlayer-js + --fee-profile Fee profile generated by gltest --fee-profile + --fee-preset Fee profile appeal posture: low, standard, or high + --appeal-rounds Override fee profile appeal rounds --fee-value Explicit fee deposit value --valid-until Unix timestamp after which the transaction is invalid --args Method arguments (see Argument Types below) @@ -194,6 +200,9 @@ OPTIONS (write): OPTIONS (estimate-fees): --rpc RPC URL for the network --fees Fee estimate options JSON, or a transaction fee object + --fee-profile Fee profile generated by gltest --fee-profile + --fee-preset Fee profile appeal posture: low, standard, or high + --appeal-rounds Override fee profile appeal rounds --include-report Include simulation fee accounting/report in the generated estimate output --args Method arguments for simulation-derived estimates @@ -205,11 +214,14 @@ EXAMPLES: genlayer deploy --contract ./my_contract.gpy genlayer deploy --contract ./my_contract.gpy --args "arg1" "arg2" 123 genlayer deploy --contract ./my_contract.gpy --fees '{"distribution":{"leaderTimeunitsAllocation":"100","validatorTimeunitsAllocation":"200","rotations":["0"]}}' + genlayer deploy --contract ./my_contract.gpy --fee-profile ./artifacts/fee-profile.json genlayer call 0x123456789abcdef greet --args "Hello World!" genlayer write 0x123456789abcdef updateValue --args 42 genlayer write 0x123456789abcdef updateValue --fees '{"distribution":{"leaderTimeunitsAllocation":"100","validatorTimeunitsAllocation":"200","rotations":["0"]}}' --args 42 + genlayer write 0x123456789abcdef updateValue --fee-profile ./artifacts/fee-profile.json --fee-preset standard --args 42 genlayer estimate-fees genlayer estimate-fees 0x123456789abcdef updateValue --args 42 + genlayer estimate-fees 0x123456789abcdef updateValue --fee-profile ./artifacts/fee-profile.json --json genlayer write 0x123456789abcdef sendReward --args 0x6857Ed54CbafaA74Fc0357145eC0ee1536ca45A0 genlayer write 0x123456789abcdef setScores --args '[1, 2, 3]' genlayer write 0x123456789abcdef setConfig --args '{"timeout": 30, "retries": 5}' @@ -218,6 +230,28 @@ EXAMPLES: ##### Transaction Fee Options +For reproducible application presets, pass the profile produced by +`gltest --fee-profile`: + +```bash +genlayer estimate-fees 0x123456789abcdef settle \ + --fee-profile ./artifacts/fee-profile.json \ + --fee-preset standard \ + --json + +genlayer write 0x123456789abcdef settle \ + --fee-profile ./artifacts/fee-profile.json \ + --fee-preset standard +``` + +`deploy` reads the profile's `deploy` entry. `write` and targeted +`estimate-fees` read `methods[method]`. The CLI converts the measured profile +entry into SDK fee-estimate options, asks `genlayer-js` for a transaction fee +preset, then sends that preset with the transaction. `--fee-preset` controls the +default appeal posture (`low`, `standard`, or `high`); use `--appeal-rounds` +for an explicit override. `--fees` can still be provided alongside +`--fee-profile` to override individual values, including `messageAllocations`. + `--fees` accepts the same transaction fee object as `genlayer-js`. Quote large integer values as strings to preserve precision. `messageAllocations[].messageType` may be `"internal"`, `"external"`, `0`, or `1`. @@ -253,29 +287,32 @@ preset for reproducible gas-unit debugging. The `--args` option automatically detects and converts values to the correct type: -| Type | Syntax | Example | -|------|--------|---------| -| Boolean | `true`, `false` | `--args true false` | -| Null | `null` | `--args null` | -| Integer | numeric value | `--args 42 -1` | -| Hex integer | `0x` prefix | `--args 0x1a` | -| String | any other value | `--args hello "multi word"` | -| Address | 40 hex chars with `0x` or `addr#` prefix | `--args 0x6857...a0` or `--args addr#6857...a0` | -| Bytes | `b#` prefix + hex | `--args b#deadbeef` | -| Array | JSON array in quotes | `--args '[1, 2, "three"]'` | -| Dict | JSON object in quotes | `--args '{"key": "value"}'` | +| Type | Syntax | Example | +| ----------- | ---------------------------------------- | ----------------------------------------------- | +| Boolean | `true`, `false` | `--args true false` | +| Null | `null` | `--args null` | +| Integer | numeric value | `--args 42 -1` | +| Hex integer | `0x` prefix | `--args 0x1a` | +| String | any other value | `--args hello "multi word"` | +| Address | 40 hex chars with `0x` or `addr#` prefix | `--args 0x6857...a0` or `--args addr#6857...a0` | +| Bytes | `b#` prefix + hex | `--args b#deadbeef` | +| Array | JSON array in quotes | `--args '[1, 2, "three"]'` | +| Dict | JSON object in quotes | `--args '{"key": "value"}'` | Large numbers that exceed JavaScript's safe integer range are automatically handled as BigInt to preserve precision. ##### Deploy Behavior + - If `--contract` is specified, the command will **deploy the given contract**. - If `--contract` is omitted, the CLI will **search for scripts inside the `deploy` folder**, sort them, and execute them sequentially. ##### Call vs Write + - `call` - Calls a contract method without sending a transaction or changing the state (read-only) - `write` - Sends a transaction to a contract method that modifies the state ##### Schema + - `schema` - Retrieves the contract schema #### Transaction Operations diff --git a/docs/api-references/contracts/deploy.mdx b/docs/api-references/contracts/deploy.mdx index b0677d0..dddbf07 100644 --- a/docs/api-references/contracts/deploy.mdx +++ b/docs/api-references/contracts/deploy.mdx @@ -10,8 +10,14 @@ Deploy intelligent contracts ### Options -| Short | Long | Description | Required | Default | -| --- | --- | --- | :---: | --- | -| | --contract <contractPath> | Path to the smart contract to deploy | No | | -| | --rpc <rpcUrl> | RPC URL for the network | No | | -| -h | --help | display help for command | No | | +| Short | Long | Description | Required | Default | +| ----- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------: | ------- | +| | --contract <contractPath> | Path to the smart contract to deploy | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --fees <json> | Transaction fee options JSON passed to genlayer-js. | No | | +| | --fee-profile <path> | Path to a fee profile generated by gltest --fee-profile. Deploy uses the profile deploy entry; write and targeted estimate-fees use the matching method entry. --fees can still be provided to override profile values. | No | | +| | --fee-preset <preset> | Fee profile appeal posture: low, standard, or high | No | | +| | --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 | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/contracts/write.mdx b/docs/api-references/contracts/write.mdx index 9050af7..0bb09d2 100644 --- a/docs/api-references/contracts/write.mdx +++ b/docs/api-references/contracts/write.mdx @@ -15,7 +15,13 @@ Sends a transaction to a contract method that modifies the state ### Options -| Short | Long | Description | Required | Default | -| --- | --- | --- | :---: | --- | -| | --rpc <rpcUrl> | RPC URL for the network | No | | -| -h | --help | display help for command | No | | +| Short | Long | Description | Required | Default | +| ----- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------: | ------- | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --fees <json> | Transaction fee options JSON passed to genlayer-js. | No | | +| | --fee-profile <path> | Path to a fee profile generated by gltest --fee-profile. Deploy uses the profile deploy entry; write and targeted estimate-fees use the matching method entry. --fees can still be provided to override profile values. | No | | +| | --fee-preset <preset> | Fee profile appeal posture: low, standard, or high | No | | +| | --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 | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/estimate-fees.mdx b/docs/api-references/estimate-fees.mdx new file mode 100644 index 0000000..88154ea --- /dev/null +++ b/docs/api-references/estimate-fees.mdx @@ -0,0 +1,28 @@ +--- +title: estimate-fees +--- + +Build a transaction fee preset, optionally from a Studio/localnet write +simulation + +### Usage + +`$ genlayer estimate-fees [options] [contractAddress] [method]` + +### Arguments + +- `[contractAddress]` +- `[method]` + +### Options + +| Short | Long | Description | Required | Default | +| ----- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------: | ------- | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --fees <json> | Fee estimate options JSON passed to genlayer-js estimateTransactionFees. | No | | +| | --fee-profile <path> | Path to a fee profile generated by gltest --fee-profile. Deploy uses the profile deploy entry; write and targeted estimate-fees use the matching method entry. --fees can still be provided to override profile values. | No | | +| | --fee-preset <preset> | Fee profile appeal posture: low, standard, or high | No | | +| | --appeal-rounds <count> | Override fee profile appeal rounds | No | | +| | --json | Print the fee estimate as JSON without spinner output | No | | +| | --include-report | Include simulation fee accounting/report in the generated estimate output | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/index.mdx b/docs/api-references/index.mdx index 49295cf..60c40a5 100644 --- a/docs/api-references/index.mdx +++ b/docs/api-references/index.mdx @@ -17,6 +17,7 @@ Version: `0.34.0` - `genlayer deploy` — Deploy intelligent contracts - `genlayer call` — Call a contract method without sending a transaction or changing the state - `genlayer write` — Sends a transaction to a contract method that modifies the state +- `genlayer estimate-fees` — Build a transaction fee preset, optionally from a Studio/localnet write simulation - `genlayer schema` — Get the schema for a deployed contract - `genlayer code` — Get the source for a deployed contract - `genlayer config` — Manage CLI configuration, including the default network diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index 9fad7e6..845831c 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -4,7 +4,7 @@ import {BaseAction} from "../../lib/actions/BaseAction"; import {pathToFileURL} from "url"; import {formatStakingAmount} from "genlayer-js"; import {buildSync} from "esbuild"; -import {ContractFeeCliOptions, parseTransactionFees, parseValidUntil} from "./fees"; +import {ContractFeeCliOptions, parseValidUntil, resolveTransactionFees} from "./fees"; import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution"; export interface DeployOptions extends ContractFeeCliOptions { @@ -133,7 +133,10 @@ export class DeployAction extends BaseAction { const leaderOnly = false; const deployParams: any = {code: contractCode, args: options.args, leaderOnly}; - const fees = parseTransactionFees(options, {deployTargeted: true}); + const fees = await resolveTransactionFees(client, options, { + deployTargeted: true, + profileTarget: {kind: "deploy"}, + }); const validUntil = parseValidUntil(options); if (fees) deployParams.fees = fees; if (validUntil !== undefined) deployParams.validUntil = validUntil; @@ -160,8 +163,10 @@ export class DeployAction extends BaseAction { this.log("Consensus Status:", transactionConsensusStatus(result)); const contractAddress = - result.data?.contract_address ?? // localnet/studio - (result.txDataDecoded as any)?.contractAddress; // testnet + // localnet/studio + result.data?.contract_address ?? + // testnet + (result.txDataDecoded as any)?.contractAddress; this.succeedSpinner("Contract deployed successfully.", { "Transaction Hash": hash, diff --git a/src/commands/contracts/estimateFees.ts b/src/commands/contracts/estimateFees.ts index fa3cf87..10e5b11 100644 --- a/src/commands/contracts/estimateFees.ts +++ b/src/commands/contracts/estimateFees.ts @@ -1,19 +1,14 @@ import {BaseAction} from "../../lib/actions/BaseAction"; -import {ContractFeeCliOptions, parseFeeEstimateOptions} from "./fees"; +import {ContractFeeCliOptions, FeeProfileTarget, parseFeeEstimateOptions, toTransactionFees} from "./fees"; -export interface EstimateFeesOptions extends Pick { +export interface EstimateFeesOptions + extends Pick { args?: any[]; rpc?: string; json?: boolean; includeReport?: boolean; } -const toTransactionFees = (estimate: Record): Record => ({ - distribution: estimate.distribution, - ...(estimate.messageAllocations ? {messageAllocations: estimate.messageAllocations} : {}), - feeValue: estimate.feeValue ?? estimate.fee_value, -}); - const toJsonSafe = (value: any): any => { if (typeof value === "bigint") return value.toString(); if (Array.isArray(value)) return value.map(toJsonSafe); @@ -27,9 +22,8 @@ const toJsonSafe = (value: any): any => { return value; }; -const simulationFeeReport = (simulation: Record): Record | undefined => ( - simulation.feeReport ?? simulation.feeAccounting?.execution_fee_report -); +const simulationFeeReport = (simulation: Record): Record | undefined => + simulation.feeReport ?? simulation.feeAccounting?.execution_fee_report; const withSimulationReport = (estimate: unknown, simulation: unknown): unknown => { if (!simulation || typeof simulation !== "object" || Array.isArray(simulation)) { @@ -39,7 +33,7 @@ const withSimulationReport = (estimate: unknown, simulation: unknown): unknown = const simulationRecord = simulation as Record; return { ...(estimate && typeof estimate === "object" && !Array.isArray(estimate) - ? estimate as Record + ? (estimate as Record) : {estimate}), simulation: { feeAccounting: simulationRecord.feeAccounting, @@ -59,6 +53,9 @@ export class EstimateFeesAction extends BaseAction { args, rpc, fees, + feeProfile, + feePreset, + appealRounds, json, includeReport, }: EstimateFeesOptions & { @@ -68,17 +65,22 @@ export class EstimateFeesAction extends BaseAction { try { const client = await this.getClient(rpc, true); await client.initializeConsensusSmartContract(); - const estimateOptions = parseFeeEstimateOptions({fees}); if (!json) this.startSpinner("Estimating transaction fees..."); let estimate: unknown; if (contractAddress || method) { if (!contractAddress || !method) { - this.failSpinner("Both contractAddress and method are required for simulation-derived fee estimates."); + this.failSpinner( + "Both contractAddress and method are required for simulation-derived fee estimates.", + ); return; } + const estimateOptions = parseFeeEstimateOptions( + {fees, feeProfile, feePreset, appealRounds}, + {profileTarget: {kind: "method", method}}, + ); if (!json) this.setSpinnerText(`Simulating ${method} on ${contractAddress}...`); if (!includeReport && typeof client.estimateTransactionFeesForWrite === "function") { estimate = await client.estimateTransactionFeesForWrite({ @@ -93,7 +95,9 @@ export class EstimateFeesAction extends BaseAction { return; } if (typeof client.estimateTransactionFeesFromSimulation !== "function") { - this.failSpinner("The active genlayer-js client does not support simulation-derived fee estimates."); + this.failSpinner( + "The active genlayer-js client does not support simulation-derived fee estimates.", + ); return; } @@ -118,6 +122,11 @@ export class EstimateFeesAction extends BaseAction { this.failSpinner("--include-report requires both contractAddress and method."); return; } + const profileTarget: FeeProfileTarget | undefined = feeProfile ? {kind: "deploy"} : undefined; + const estimateOptions = parseFeeEstimateOptions( + {fees, feeProfile, feePreset, appealRounds}, + {profileTarget}, + ); estimate = await client.estimateTransactionFees(estimateOptions); } diff --git a/src/commands/contracts/fees.ts b/src/commands/contracts/fees.ts index acd914d..f276e96 100644 --- a/src/commands/contracts/fees.ts +++ b/src/commands/contracts/fees.ts @@ -1,15 +1,40 @@ -import { - DEPLOY_CALL_KEY, - deriveExternalMessageCallKey, - deriveInternalMessageCallKey, -} from "genlayer-js"; +import fs from "fs"; +import path from "path"; +import {DEPLOY_CALL_KEY, deriveExternalMessageCallKey, deriveInternalMessageCallKey} from "genlayer-js"; export interface ContractFeeCliOptions { fees?: string; + feeProfile?: string; + feePreset?: string; + appealRounds?: string; feeValue?: string; validUntil?: string; } +export type FeeProfileTarget = {kind: "deploy"} | {kind: "method"; method: string}; + +type FeeParseConfig = { + deployTargeted?: boolean; + profileTarget?: FeeProfileTarget; +}; + +const FEE_PROFILE_PRESET_APPEAL_ROUNDS: Record = { + low: "0", + standard: "1", + high: "2", +}; + +const FEE_PROFILE_FIELDS = [ + "leaderTimeunitsAllocation", + "validatorTimeunitsAllocation", + "executionBudgetPerRound", + "executionConsumed", + "totalMessageFees", + "maxPriceGenPerTimeUnit", + "storageFeeMaxGasPrice", + "receiptFeeMaxGasPrice", +]; + const parseJsonObject = (value: string, optionName: string): Record => { let parsed: unknown; try { @@ -52,6 +77,23 @@ const parseBigNumberishOption = (value: string | undefined, optionName: string): return trimmed; }; +const toSafeNonNegativeNumber = (value: string, optionName: string): number => { + const parsed = BigInt(value); + if (parsed > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`${optionName} is too large.`); + } + return Number(parsed); +}; + +const parseProfilePresetAppealRounds = (options: ContractFeeCliOptions): string => { + const preset = options.feePreset ?? "standard"; + const appealRounds = FEE_PROFILE_PRESET_APPEAL_ROUNDS[preset]; + if (appealRounds === undefined) { + throw new Error("--fee-preset must be one of: low, standard, high."); + } + return appealRounds; +}; + const normalizeMessageType = (messageType: unknown, index: number): 0 | 1 | undefined => { if (messageType === undefined) { return undefined; @@ -61,7 +103,9 @@ const normalizeMessageType = (messageType: unknown, index: number): 0 | 1 | unde if (messageType === 0 || messageType === 1) { return messageType; } - throw new Error(`--fees.messageAllocations[${index}].messageType must be "internal", "external", 0, or 1.`); + throw new Error( + `--fees.messageAllocations[${index}].messageType must be "internal", "external", 0, or 1.`, + ); } if (typeof messageType !== "string") { @@ -103,27 +147,20 @@ const normalizeMessageAllocationCallKey = ( messageType: 0 | 1 | undefined, index: number, ): Record => { - const helperFields = [ - "callKeyMethod", - "callKeySelector", - "callKeyCalldata", - "functionSelector", - ].filter((field) => allocation[field] !== undefined); + const helperFields = ["callKeyMethod", "callKeySelector", "callKeyCalldata", "functionSelector"].filter( + field => allocation[field] !== undefined, + ); if (allocation.callKey !== undefined && helperFields.length > 0) { - throw new Error(`--fees.messageAllocations[${index}] cannot combine callKey with call-key helper fields.`); + throw new Error( + `--fees.messageAllocations[${index}] cannot combine callKey with call-key helper fields.`, + ); } if (helperFields.length > 1) { throw new Error(`--fees.messageAllocations[${index}] must use only one call-key helper field.`); } - const { - callKeyMethod, - callKeySelector, - callKeyCalldata, - functionSelector, - ...normalized - } = allocation; + const {callKeyMethod, callKeySelector, callKeyCalldata, functionSelector, ...normalized} = allocation; if (helperFields.length === 0) { return normalized; @@ -132,7 +169,9 @@ const normalizeMessageAllocationCallKey = ( const helperField = helperFields[0]; if (helperField === "callKeyMethod") { if (messageType === 0) { - throw new Error(`--fees.messageAllocations[${index}].callKeyMethod requires an internal message allocation.`); + throw new Error( + `--fees.messageAllocations[${index}].callKeyMethod requires an internal message allocation.`, + ); } normalized.messageType = messageType ?? 1; normalized.callKey = deriveInternalMessageCallKey(readStringField(allocation, helperField, index)); @@ -140,7 +179,9 @@ const normalizeMessageAllocationCallKey = ( } if (messageType === 1) { - throw new Error(`--fees.messageAllocations[${index}].${helperField} requires an external message allocation.`); + throw new Error( + `--fees.messageAllocations[${index}].${helperField} requires an external message allocation.`, + ); } const selectorOrCalldata = readStringField(allocation, helperField, index); @@ -167,10 +208,14 @@ const normalizeMessageTypes = (fees: Record, deployTargeted = false } const messageType = normalizeMessageType(allocation.messageType, index); - const normalized = normalizeMessageAllocationCallKey({ - ...allocation, - ...(messageType === undefined ? {} : {messageType}), - }, messageType, index); + const normalized = normalizeMessageAllocationCallKey( + { + ...allocation, + ...(messageType === undefined ? {} : {messageType}), + }, + messageType, + index, + ); if (deployTargeted && normalized.callKey === undefined) { normalized.callKey = DEPLOY_CALL_KEY; } @@ -179,9 +224,105 @@ const normalizeMessageTypes = (fees: Record, deployTargeted = false }; }; +const flattenFeeEstimateOptions = ( + parsed: Record, + config: FeeParseConfig = {}, +): Record => { + const normalized = normalizeMessageTypes(parsed, config.deployTargeted); + if ( + normalized.distribution && + typeof normalized.distribution === "object" && + !Array.isArray(normalized.distribution) + ) { + const {distribution, messageAllocations, ...rest} = normalized; + return { + ...distribution, + ...(messageAllocations !== undefined ? {messageAllocations} : {}), + ...rest, + }; + } + return normalized; +}; + +const readFeeProfile = (profilePath: string): Record => { + const resolvedPath = path.resolve(profilePath); + let content: string; + try { + content = fs.readFileSync(resolvedPath, "utf-8"); + } catch (error) { + throw new Error(`Unable to read --fee-profile at ${resolvedPath}.`); + } + return parseJsonObject(content, "--fee-profile"); +}; + +const feeProfileEntry = (profile: Record, target: FeeProfileTarget): Record => { + const entry = target.kind === "deploy" ? profile.deploy : profile.methods?.[target.method]; + + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + const targetLabel = target.kind === "deploy" ? "deploy" : `method "${target.method}"`; + throw new Error(`--fee-profile does not contain a fee profile for ${targetLabel}.`); + } + return entry; +}; + +const profileEntryToEstimateOptions = ( + entry: Record, + options: ContractFeeCliOptions, +): Record => { + assertSafeJsonNumbers(entry, "--fee-profile entry"); + const result: Record = {}; + + for (const key of FEE_PROFILE_FIELDS) { + if (entry[key] !== undefined) { + result[key] = entry[key]; + } + } + + if (entry.messageAllocations !== undefined) { + result.messageAllocations = entry.messageAllocations; + } + + if (entry.rotations !== undefined) { + result.rotations = entry.rotations; + if (entry.appealRounds !== undefined) { + result.appealRounds = entry.appealRounds; + } + return result; + } + + const appealRounds = parseBigNumberishOption( + options.appealRounds ?? entry.appealRounds?.toString() ?? parseProfilePresetAppealRounds(options), + "--appeal-rounds", + )!; + const rotationsPerRound = parseBigNumberishOption( + entry.rotationsPerRound?.toString() ?? "0", + "--fee-profile rotationsPerRound", + )!; + const rotationCount = toSafeNonNegativeNumber(appealRounds, "--appeal-rounds") + 1; + + result.appealRounds = appealRounds; + result.rotations = Array(rotationCount).fill(rotationsPerRound); + return result; +}; + +const parseProfileEstimateOptions = ( + options: ContractFeeCliOptions, + config: FeeParseConfig = {}, +): Record | undefined => { + if (!options.feeProfile) { + return undefined; + } + + const target = config.profileTarget ?? {kind: "deploy" as const}; + return flattenFeeEstimateOptions( + profileEntryToEstimateOptions(feeProfileEntry(readFeeProfile(options.feeProfile), target), options), + config, + ); +}; + export const parseTransactionFees = ( options: ContractFeeCliOptions, - config: {deployTargeted?: boolean} = {}, + config: FeeParseConfig = {}, ): Record | undefined => { const feeValue = parseBigNumberishOption(options.feeValue, "--fee-value"); let fees = options.fees ? parseJsonObject(options.fees, "--fees") : undefined; @@ -197,21 +338,51 @@ export const parseTransactionFees = ( return fees; }; -export const parseFeeEstimateOptions = (options: Pick): Record | undefined => { +export const parseFeeEstimateOptions = ( + options: Pick, + config: FeeParseConfig = {}, +): Record | undefined => { + const profileOptions = parseProfileEstimateOptions(options, config); if (!options.fees) { - return undefined; + return profileOptions; } - const parsed = normalizeMessageTypes(parseJsonObject(options.fees, "--fees")); - if (parsed.distribution && typeof parsed.distribution === "object" && !Array.isArray(parsed.distribution)) { - const {distribution, messageAllocations, ...rest} = parsed; - return { - ...distribution, - ...(messageAllocations !== undefined ? {messageAllocations} : {}), - ...rest, - }; + const explicitOptions = flattenFeeEstimateOptions(parseJsonObject(options.fees, "--fees"), config); + if (!profileOptions) { + return explicitOptions; + } + return { + ...profileOptions, + ...explicitOptions, + }; +}; + +export const toTransactionFees = (estimate: Record): Record => ({ + distribution: estimate.distribution, + ...(estimate.messageAllocations ? {messageAllocations: estimate.messageAllocations} : {}), + feeValue: estimate.feeValue ?? estimate.fee_value, +}); + +export const resolveTransactionFees = async ( + client: {estimateTransactionFees?: (options?: Record) => Promise>}, + options: ContractFeeCliOptions, + config: FeeParseConfig = {}, +): Promise | undefined> => { + if (!options.feeProfile) { + return parseTransactionFees(options, config); + } + + if (typeof client.estimateTransactionFees !== "function") { + throw new Error("The active genlayer-js client does not support fee profile estimation."); + } + + const estimateOptions = parseFeeEstimateOptions(options, config); + const transactionFees = toTransactionFees(await client.estimateTransactionFees(estimateOptions)); + const feeValue = parseBigNumberishOption(options.feeValue, "--fee-value"); + if (feeValue !== undefined) { + transactionFees.feeValue = feeValue; } - return parsed; + return transactionFees; }; export const parseValidUntil = (options: ContractFeeCliOptions): string | undefined => { diff --git a/src/commands/contracts/index.ts b/src/commands/contracts/index.ts index 07c2f99..c18b5df 100644 --- a/src/commands/contracts/index.ts +++ b/src/commands/contracts/index.ts @@ -78,7 +78,7 @@ const ARGS_HELP = [ ' str: hello, "multi word"', " address: 0x6857...a0 (40 hex chars) or addr#6857...a0", " bytes: b#deadbeef", - ' array: \'[1, 2, "three"]\'', + " array: '[1, 2, \"three\"]'", ' dict: \'{"key": "value"}\'', ].join("\n"); @@ -100,6 +100,12 @@ const FEE_ESTIMATE_HELP = [ "Use callKeyMethod for internal messages, or callKeySelector/callKeyCalldata for external messages.", ].join("\n"); +const FEE_PROFILE_HELP = [ + "Path to a fee profile generated by gltest --fee-profile.", + "Deploy uses the profile deploy entry; write and targeted estimate-fees use the matching method entry.", + "--fees can still be provided to override profile values.", +].join("\n"); + export function initializeContractsCommands(program: Command) { program .command("deploy") @@ -107,6 +113,9 @@ export function initializeContractsCommands(program: Command) { .option("--contract ", "Path to the smart contract to deploy") .option("--rpc ", "RPC URL for the network") .option("--fees ", FEES_HELP) + .option("--fee-profile ", FEE_PROFILE_HELP) + .option("--fee-preset ", "Fee profile appeal posture: low, standard, or high") + .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("--args ", ARGS_HELP, parseArg, []) @@ -124,12 +133,7 @@ export function initializeContractsCommands(program: Command) { .command("call ") .description("Call a contract method without sending a transaction or changing the state") .option("--rpc ", "RPC URL for the network") - .option( - "--args ", - ARGS_HELP, - parseArg, - [], - ) + .option("--args ", ARGS_HELP, parseArg, []) .action(async (contractAddress: string, method: string, options: CallOptions) => { const callAction = new CallAction(); await callAction.call({contractAddress, method, ...options}); @@ -140,14 +144,12 @@ export function initializeContractsCommands(program: Command) { .description("Sends a transaction to a contract method that modifies the state") .option("--rpc ", "RPC URL for the network") .option("--fees ", FEES_HELP) + .option("--fee-profile ", FEE_PROFILE_HELP) + .option("--fee-preset ", "Fee profile appeal posture: low, standard, or high") + .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( - "--args ", - ARGS_HELP, - parseArg, - [], - ) + .option("--args ", ARGS_HELP, parseArg, []) .action(async (contractAddress: string, method: string, options: WriteOptions) => { const writeAction = new WriteAction(); await writeAction.write({contractAddress, method, ...options}); @@ -158,18 +160,22 @@ export function initializeContractsCommands(program: Command) { .description("Build a transaction fee preset, optionally from a Studio/localnet write simulation") .option("--rpc ", "RPC URL for the network") .option("--fees ", FEE_ESTIMATE_HELP) + .option("--fee-profile ", FEE_PROFILE_HELP) + .option("--fee-preset ", "Fee profile appeal posture: low, standard, or high") + .option("--appeal-rounds ", "Override fee profile appeal rounds") .option("--json", "Print the fee estimate as JSON without spinner output") .option("--include-report", "Include simulation fee accounting/report in the generated estimate output") - .option( - "--args ", - ARGS_HELP, - parseArg, - [], - ) - .action(async (contractAddress: string | undefined, method: string | undefined, options: EstimateFeesOptions) => { - const estimateFeesAction = new EstimateFeesAction(); - await estimateFeesAction.estimate({contractAddress, method, ...options}); - }); + .option("--args ", ARGS_HELP, parseArg, []) + .action( + async ( + contractAddress: string | undefined, + method: string | undefined, + options: EstimateFeesOptions, + ) => { + const estimateFeesAction = new EstimateFeesAction(); + await estimateFeesAction.estimate({contractAddress, method, ...options}); + }, + ); program .command("schema ") diff --git a/src/commands/contracts/write.ts b/src/commands/contracts/write.ts index a30aacb..42622b8 100644 --- a/src/commands/contracts/write.ts +++ b/src/commands/contracts/write.ts @@ -2,7 +2,7 @@ // import type {GenLayerClient} from "genlayer-js/types"; import {formatStakingAmount} from "genlayer-js"; import {BaseAction} from "../../lib/actions/BaseAction"; -import {ContractFeeCliOptions, parseTransactionFees, parseValidUntil} from "./fees"; +import {ContractFeeCliOptions, parseValidUntil, resolveTransactionFees} from "./fees"; import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution"; export interface WriteOptions extends ContractFeeCliOptions { @@ -21,16 +21,14 @@ export class WriteAction extends BaseAction { args, rpc, fees, + feeProfile, + feePreset, + appealRounds, feeValue, validUntil, - }: { + }: WriteOptions & { contractAddress: string; method: string; - args: any[]; - rpc?: string; - fees?: string; - feeValue?: string; - validUntil?: string; }): Promise { const client = await this.getClient(rpc); await client.initializeConsensusSmartContract(); @@ -43,8 +41,19 @@ export class WriteAction extends BaseAction { args, value: 0n, }; - const parsedFees = parseTransactionFees({fees, feeValue, validUntil}); - const parsedValidUntil = parseValidUntil({fees, feeValue, validUntil}); + const parsedFees = await resolveTransactionFees( + client, + {fees, feeProfile, feePreset, appealRounds, feeValue, validUntil}, + {profileTarget: {kind: "method", method}}, + ); + const parsedValidUntil = parseValidUntil({ + fees, + feeProfile, + feePreset, + appealRounds, + feeValue, + validUntil, + }); if (parsedFees) writeParams.fees = parsedFees; if (parsedValidUntil !== undefined) writeParams.validUntil = parsedValidUntil; if (parsedFees?.feeValue !== undefined) { diff --git a/tests/actions/deploy.test.ts b/tests/actions/deploy.test.ts index 82ec1bc..5df2a06 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -19,6 +19,7 @@ describe("DeployAction", () => { deployContract: vi.fn(), waitForTransactionReceipt: vi.fn(), initializeConsensusSmartContract: vi.fn(), + estimateTransactionFees: vi.fn(), }; const mockPrivateKey = "mocked_private_key"; @@ -35,9 +36,9 @@ describe("DeployAction", () => { vi.mocked(formatStakingAmount).mockImplementation((value: bigint) => `${value.toString()} GEN`); vi.mocked(isSuccessful).mockImplementation((receipt: any) => { const statusName = receipt.statusName ?? receipt.status; - const executionResultName = receipt.txExecutionResultName ?? ( - receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined - ); + const executionResultName = + receipt.txExecutionResultName ?? + (receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined); return ( (statusName === "ACCEPTED" || statusName === "FINALIZED") && executionResultName === "FINISHED_WITH_RETURN" @@ -124,11 +125,13 @@ describe("DeployAction", () => { leaderTimeunitsAllocation: "10", rotations: ["0"], }, - messageAllocations: [{ - messageType: "internal", - recipient: "0x0000000000000000000000000000000000000001", - budget: "5", - }], + messageAllocations: [ + { + messageType: "internal", + recipient: "0x0000000000000000000000000000000000000001", + budget: "5", + }, + ], }), feeValue: "123", validUntil: "999", @@ -155,18 +158,88 @@ describe("DeployAction", () => { leaderTimeunitsAllocation: "10", rotations: ["0"], }, - messageAllocations: [{ - messageType: 1, - recipient: "0x0000000000000000000000000000000000000001", - callKey: "0x0000000000000000000000000000000000000000000000000000000000000001", - budget: "5", - }], + messageAllocations: [ + { + messageType: 1, + recipient: "0x0000000000000000000000000000000000000001", + callKey: "0x0000000000000000000000000000000000000000000000000000000000000001", + budget: "5", + }, + ], feeValue: "123", }, validUntil: "999", }); }); + test("deploys contract with fees estimated from a fee profile", async () => { + const options: DeployOptions = { + contract: "/mocked/contract/path", + args: [1], + feeProfile: "/mocked/fee-profile.json", + feeValue: "999", + }; + const contractContent = "contract code"; + const feeProfile = { + version: 1, + network: "localnet", + deploy: { + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "0", + rotationsPerRound: "1", + }, + methods: {}, + }; + const feeEstimate = { + distribution: { + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "0", + appealRounds: "1", + rotations: ["1", "1"], + }, + feeValue: "123", + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation(((filePath: string) => { + const normalizedPath = filePath.replace(/\\/g, "/"); + if (normalizedPath === "/mocked/contract/path") return contractContent; + if (normalizedPath.endsWith("/fee-profile.json")) return JSON.stringify(feeProfile); + return JSON.stringify({activeAccount: "default"}); + }) as any); + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue(feeEstimate); + 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.estimateTransactionFees).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "0", + appealRounds: "1", + rotations: ["1", "1"], + }); + expect(mockClient.deployContract).toHaveBeenCalledWith({ + code: contractContent, + args: [1], + leaderOnly: false, + fees: { + distribution: feeEstimate.distribution, + feeValue: "999", + }, + }); + }); + test("fails when deployment reaches consensus but execution fails", async () => { const options: DeployOptions = { contract: "/mocked/contract/path", diff --git a/tests/actions/estimateFees.test.ts b/tests/actions/estimateFees.test.ts index cf07246..605f4ef 100644 --- a/tests/actions/estimateFees.test.ts +++ b/tests/actions/estimateFees.test.ts @@ -1,4 +1,7 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; import {createClient, createAccount, deriveInternalMessageCallKey} from "genlayer-js"; import {EstimateFeesAction} from "../../src/commands/contracts/estimateFees"; @@ -16,13 +19,21 @@ describe("EstimateFeesAction", () => { const mockPrivateKey = "mocked_private_key"; + const writeFeeProfile = (profile: Record): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "genlayer-cli-fees-")); + const profilePath = path.join(dir, "fee-profile.json"); + fs.writeFileSync(profilePath, JSON.stringify(profile)); + return profilePath; + }; + beforeEach(() => { vi.clearAllMocks(); vi.mocked(createClient).mockReturnValue(mockClient as any); vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any); - vi.mocked(deriveInternalMessageCallKey).mockImplementation((methodName = "") => ( - `0x${Buffer.from(methodName, "utf8").toString("hex").padEnd(64, "0")}` as `0x${string}` - )); + vi.mocked(deriveInternalMessageCallKey).mockImplementation( + (methodName = "") => + `0x${Buffer.from(methodName, "utf8").toString("hex").padEnd(64, "0")}` as `0x${string}`, + ); action = new EstimateFeesAction(); vi.spyOn(action as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey}); vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); @@ -64,6 +75,55 @@ describe("EstimateFeesAction", () => { }); }); + test("builds a static fee estimate from the deploy fee profile entry", async () => { + const profilePath = writeFeeProfile({ + version: 1, + network: "localnet", + deploy: { + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "0", + rotationsPerRound: "1", + }, + methods: {}, + }); + const estimate = { + distribution: { + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + executionBudgetPerRound: 300n, + totalMessageFees: 0n, + appealRounds: 1n, + rotations: [1n, 1n], + }, + feeValue: 1700n, + }; + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue(estimate); + + await action.estimate({feeProfile: profilePath}); + + expect(mockClient.estimateTransactionFees).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "0", + appealRounds: "1", + rotations: ["1", "1"], + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Fee estimate generated", { + distribution: { + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "0", + appealRounds: "1", + rotations: ["1", "1"], + }, + feeValue: "1700", + }); + }); + test("prints a static fee estimate as JSON without spinner output", async () => { const estimate = { distribution: {leaderTimeunitsAllocation: 100n, rotations: [0n]}, @@ -77,11 +137,13 @@ describe("EstimateFeesAction", () => { expect(action["startSpinner"]).not.toHaveBeenCalled(); expect(action["succeedSpinner"]).not.toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ - distribution: {leaderTimeunitsAllocation: "100", rotations: ["0"]}, - feeValue: "1100", - policy: {enabled: true}, - })); + expect(logSpy).toHaveBeenCalledWith( + JSON.stringify({ + distribution: {leaderTimeunitsAllocation: "100", rotations: ["0"]}, + feeValue: "1100", + policy: {enabled: true}, + }), + ); }); test("derives a fee estimate for a target write through the SDK one-call helper", async () => { @@ -99,20 +161,24 @@ describe("EstimateFeesAction", () => { method: "update", args: ["after"], fees: JSON.stringify({ - messageAllocations: [{ - messageType: "internal", - callKeyMethod: "settle_campaign", - budget: "110", - }], + messageAllocations: [ + { + messageType: "internal", + callKeyMethod: "settle_campaign", + budget: "110", + }, + ], }), }); expect(mockClient.estimateTransactionFeesForWrite).toHaveBeenCalledWith({ - messageAllocations: [{ - messageType: 1, - callKey: `0x${Buffer.from("settle_campaign", "utf8").toString("hex").padEnd(64, "0")}`, - budget: "110", - }], + messageAllocations: [ + { + messageType: 1, + callKey: `0x${Buffer.from("settle_campaign", "utf8").toString("hex").padEnd(64, "0")}`, + budget: "110", + }, + ], address: "0x0000000000000000000000000000000000000001", functionName: "update", args: ["after"], @@ -129,6 +195,80 @@ describe("EstimateFeesAction", () => { }); }); + test("derives a target write fee estimate from the matching method fee profile entry", async () => { + const profilePath = writeFeeProfile({ + version: 1, + network: "localnet", + methods: { + update: { + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "55", + rotationsPerRound: "1", + }, + }, + }); + const finalEstimate = { + distribution: { + leaderTimeunitsAllocation: 100n, + totalMessageFees: 80n, + appealRounds: 2n, + rotations: [1n, 1n, 1n], + }, + messageAllocations: [{messageType: 1, budget: 80n}], + feeValue: 1780n, + }; + vi.mocked(mockClient.estimateTransactionFeesForWrite).mockResolvedValue(finalEstimate); + + await action.estimate({ + contractAddress: "0x0000000000000000000000000000000000000001", + method: "update", + args: ["after"], + feeProfile: profilePath, + feePreset: "high", + fees: JSON.stringify({ + totalMessageFees: "80", + messageAllocations: [ + { + messageType: "internal", + callKeyMethod: "settle_campaign", + budget: "80", + }, + ], + }), + }); + + expect(mockClient.estimateTransactionFeesForWrite).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "80", + appealRounds: "2", + rotations: ["1", "1", "1"], + messageAllocations: [ + { + messageType: 1, + callKey: `0x${Buffer.from("settle_campaign", "utf8").toString("hex").padEnd(64, "0")}`, + budget: "80", + }, + ], + address: "0x0000000000000000000000000000000000000001", + functionName: "update", + args: ["after"], + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Fee estimate generated", { + distribution: { + leaderTimeunitsAllocation: "100", + totalMessageFees: "80", + appealRounds: "2", + rotations: ["1", "1", "1"], + }, + messageAllocations: [{messageType: 1, budget: "80"}], + feeValue: "1780", + }); + }); + test("falls back to explicit simulation when the SDK one-call helper is unavailable", async () => { const legacyClient = { ...mockClient, diff --git a/tests/actions/write.test.ts b/tests/actions/write.test.ts index 1e48352..22938c0 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -1,4 +1,7 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; import { createClient, createAccount, @@ -16,10 +19,18 @@ describe("WriteAction", () => { writeContract: vi.fn(), waitForTransactionReceipt: vi.fn(), initializeConsensusSmartContract: vi.fn(), + estimateTransactionFees: vi.fn(), }; const mockPrivateKey = "mocked_private_key"; + const writeFeeProfile = (profile: Record): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "genlayer-cli-fees-")); + const profilePath = path.join(dir, "fee-profile.json"); + fs.writeFileSync(profilePath, JSON.stringify(profile)); + return profilePath; + }; + beforeEach(() => { vi.clearAllMocks(); vi.mocked(createClient).mockReturnValue(mockClient as any); @@ -27,18 +38,19 @@ describe("WriteAction", () => { vi.mocked(formatStakingAmount).mockImplementation((value: bigint) => `${value.toString()} GEN`); vi.mocked(deriveExternalMessageCallKey).mockImplementation( (selectorOrCalldata: `0x${string}` | Uint8Array = "0x") => { - const hex = typeof selectorOrCalldata === "string" - ? selectorOrCalldata.slice(2) - : Buffer.from(selectorOrCalldata).toString("hex"); + const hex = + typeof selectorOrCalldata === "string" + ? selectorOrCalldata.slice(2) + : Buffer.from(selectorOrCalldata).toString("hex"); if (hex.length < 8) return "0x0000000000000000000000000000000000000000000000000000000000000000"; return `0x${hex.slice(0, 8).padEnd(64, "0")}`; }, ); vi.mocked(isSuccessful).mockImplementation((receipt: any) => { const statusName = receipt.statusName ?? receipt.status; - const executionResultName = receipt.txExecutionResultName ?? ( - receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined - ); + const executionResultName = + receipt.txExecutionResultName ?? + (receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined); return ( (statusName === "ACCEPTED" || statusName === "FINALIZED") && executionResultName === "FINISHED_WITH_RETURN" @@ -85,10 +97,10 @@ describe("WriteAction", () => { waitUntil: "decided", fullTransaction: true, }); - expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( - "Write operation successfully executed", - {...mockReceipt, consensusStatus: "ACCEPTED"}, - ); + expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith("Write operation successfully executed", { + ...mockReceipt, + consensusStatus: "ACCEPTED", + }); }); test("calls writeContract with fee options", async () => { @@ -106,12 +118,14 @@ describe("WriteAction", () => { distribution: { totalMessageFees: "3", }, - messageAllocations: [{ - messageType: "external", - recipient: "0x0000000000000000000000000000000000000001", - callKeySelector: "0xaabbccdd", - budget: "3", - }], + messageAllocations: [ + { + messageType: "external", + recipient: "0x0000000000000000000000000000000000000001", + callKeySelector: "0xaabbccdd", + budget: "3", + }, + ], }), feeValue: "4", validUntil: "999", @@ -126,18 +140,80 @@ describe("WriteAction", () => { distribution: { totalMessageFees: "3", }, - messageAllocations: [{ - messageType: 0, - recipient: "0x0000000000000000000000000000000000000001", - callKey: `0xaabbccdd${"0".repeat(56)}`, - budget: "3", - }], + messageAllocations: [ + { + messageType: 0, + recipient: "0x0000000000000000000000000000000000000001", + callKey: `0xaabbccdd${"0".repeat(56)}`, + budget: "3", + }, + ], feeValue: "4", }, validUntil: "999", }); }); + test("calls writeContract with fees estimated from a method fee profile", async () => { + const profilePath = writeFeeProfile({ + version: 1, + network: "localnet", + methods: { + updateData: { + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "5", + rotationsPerRound: "1", + }, + }, + }); + const feeEstimate = { + distribution: { + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "5", + appealRounds: "2", + rotations: ["1", "1", "1"], + }, + feeValue: "123", + }; + const mockHash = "0xMockedTransactionHash"; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; + + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue(feeEstimate); + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); + + await writeAction.write({ + contractAddress: "0xMockedContract", + method: "updateData", + args: [42], + feeProfile: profilePath, + feePreset: "high", + }); + + expect(mockClient.estimateTransactionFees).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "5", + appealRounds: "2", + rotations: ["1", "1", "1"], + }); + expect(mockClient.writeContract).toHaveBeenCalledWith({ + address: "0xMockedContract", + functionName: "updateData", + args: [42], + value: 0n, + fees: { + distribution: feeEstimate.distribution, + feeValue: "123", + }, + }); + }); + test("handles writeContract errors", async () => { vi.mocked(mockClient.writeContract).mockRejectedValue(new Error("Mocked write error")); @@ -290,9 +366,9 @@ describe("WriteAction", () => { args: [42, "Update"], value: 0n, }); - expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( - "Write operation successfully executed", - {...mockReceipt, consensusStatus: "ACCEPTED"}, - ); + expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith("Write operation successfully executed", { + ...mockReceipt, + consensusStatus: "ACCEPTED", + }); }); }); diff --git a/tests/commands/deploy.test.ts b/tests/commands/deploy.test.ts index 271e60e..85a493f 100644 --- a/tests/commands/deploy.test.ts +++ b/tests/commands/deploy.test.ts @@ -1,7 +1,7 @@ -import { Command } from "commander"; -import { vi, describe, beforeEach, afterEach, test, expect } from "vitest"; -import { initializeContractsCommands } from "../../src/commands/contracts"; -import { DeployAction } from "../../src/commands/contracts/deploy"; +import {Command} from "commander"; +import {vi, describe, beforeEach, afterEach, test, expect} from "vitest"; +import {initializeContractsCommands} from "../../src/commands/contracts"; +import {DeployAction} from "../../src/commands/contracts/deploy"; vi.mock("../../src/commands/contracts/deploy"); vi.mock("esbuild", () => ({ @@ -42,13 +42,13 @@ describe("deploy command", () => { "2", "3", "--rpc", - "https://custom-rpc-url.com" + "https://custom-rpc-url.com", ]); expect(DeployAction).toHaveBeenCalledTimes(1); expect(DeployAction.prototype.deploy).toHaveBeenCalledWith({ contract: "./path/to/contract", args: [1, 2, 3], - rpc: "https://custom-rpc-url.com" + rpc: "https://custom-rpc-url.com", }); }); @@ -77,32 +77,52 @@ describe("deploy command", () => { }); }); + test("DeployAction.deploy receives fee profile options", async () => { + program.parse([ + "node", + "test", + "deploy", + "--contract", + "./path/to/contract", + "--fee-profile", + "./artifacts/fee-profile.json", + "--fee-preset", + "high", + "--appeal-rounds", + "3", + ]); + + expect(DeployAction.prototype.deploy).toHaveBeenCalledWith({ + contract: "./path/to/contract", + args: [], + feeProfile: "./artifacts/fee-profile.json", + feePreset: "high", + appealRounds: "3", + }); + }); + test("DeployAction is instantiated when the deploy command is executed", async () => { program.parse(["node", "test", "deploy", "--contract", "./path/to/contract"]); expect(DeployAction).toHaveBeenCalledTimes(1); }); test("throws error for unrecognized options", async () => { - const deployCommand = program.commands.find((cmd) => cmd.name() === "deploy"); + const deployCommand = program.commands.find(cmd => cmd.name() === "deploy"); deployCommand?.exitOverride(); expect(() => program.parse(["node", "test", "deploy", "--unknown"])).toThrowError( - "error: unknown option '--unknown'" + "error: unknown option '--unknown'", ); }); test("DeployAction.deploy is called without throwing errors for valid options", async () => { program.parse(["node", "test", "deploy", "--contract", "./path/to/contract"]); vi.mocked(DeployAction.prototype.deploy).mockResolvedValueOnce(undefined); - expect(() => - program.parse(["node", "test", "deploy", "--contract", "./path/to/contract"]) - ).not.toThrow(); + expect(() => program.parse(["node", "test", "deploy", "--contract", "./path/to/contract"])).not.toThrow(); }); test("DeployAction.deployScripts is called without throwing errors", async () => { program.parse(["node", "test", "deploy"]); vi.mocked(DeployAction.prototype.deployScripts).mockResolvedValueOnce(undefined); - expect(() => - program.parse(["node", "test", "deploy"]) - ).not.toThrow(); + expect(() => program.parse(["node", "test", "deploy"])).not.toThrow(); }); }); diff --git a/tests/commands/estimateFees.test.ts b/tests/commands/estimateFees.test.ts index 942644d..cb8ba54 100644 --- a/tests/commands/estimateFees.test.ts +++ b/tests/commands/estimateFees.test.ts @@ -23,15 +23,7 @@ describe("estimate-fees command", () => { test("EstimateFeesAction.estimate is called with static estimate options", async () => { const fees = '{"distribution":{"totalMessageFees":"3"}}'; - program.parse([ - "node", - "test", - "estimate-fees", - "--fees", - fees, - "--rpc", - "http://127.0.0.1:4000/api", - ]); + program.parse(["node", "test", "estimate-fees", "--fees", fees, "--rpc", "http://127.0.0.1:4000/api"]); expect(EstimateFeesAction).toHaveBeenCalledTimes(1); expect(EstimateFeesAction.prototype.estimate).toHaveBeenCalledWith({ @@ -43,6 +35,31 @@ describe("estimate-fees command", () => { }); }); + test("EstimateFeesAction.estimate receives fee profile options", async () => { + program.parse([ + "node", + "test", + "estimate-fees", + "0x0000000000000000000000000000000000000001", + "update", + "--fee-profile", + "./artifacts/fee-profile.json", + "--fee-preset", + "high", + "--appeal-rounds", + "3", + ]); + + expect(EstimateFeesAction.prototype.estimate).toHaveBeenCalledWith({ + args: [], + feeProfile: "./artifacts/fee-profile.json", + feePreset: "high", + appealRounds: "3", + contractAddress: "0x0000000000000000000000000000000000000001", + method: "update", + }); + }); + test("EstimateFeesAction.estimate is called with simulation target and args", async () => { program.parse([ "node", @@ -63,12 +80,7 @@ describe("estimate-fees command", () => { }); test("EstimateFeesAction.estimate receives json output flag", async () => { - program.parse([ - "node", - "test", - "estimate-fees", - "--json", - ]); + program.parse(["node", "test", "estimate-fees", "--json"]); expect(EstimateFeesAction.prototype.estimate).toHaveBeenCalledWith({ args: [], diff --git a/tests/commands/write.test.ts b/tests/commands/write.test.ts index 5af4a62..7e56231 100644 --- a/tests/commands/write.test.ts +++ b/tests/commands/write.test.ts @@ -80,6 +80,31 @@ describe("write command", () => { }); }); + test("WriteAction.write receives fee profile options", async () => { + program.parse([ + "node", + "test", + "write", + "0xMockedContract", + "updateCounter", + "--fee-profile", + "./artifacts/fee-profile.json", + "--fee-preset", + "standard", + "--appeal-rounds", + "2", + ]); + + expect(WriteAction.prototype.write).toHaveBeenCalledWith({ + contractAddress: "0xMockedContract", + method: "updateCounter", + args: [], + feeProfile: "./artifacts/fee-profile.json", + feePreset: "standard", + appealRounds: "2", + }); + }); + test("WriteAction is instantiated when the write command is executed", async () => { program.parse(["node", "test", "write", "0xMockedContract", "anotherMethod"]); expect(WriteAction).toHaveBeenCalledTimes(1);