From d0b08de94c8923f1dbb9b6d098059c5778091874 Mon Sep 17 00:00:00 2001 From: Lesnak1 Date: Fri, 23 Oct 2026 23:46:44 +0300 Subject: [PATCH] fix(deploy): fail gracefully on execution error and validate contract address Fixes #430 by ensuring that contract deployments check for leader execution errors, decode error payloads (e.g., invalid_contract), and verify contract address presence before reporting success. --- src/commands/contracts/deploy.ts | 11 ++-- src/commands/contracts/execution.ts | 62 +++++++++++++++------- tests/actions/deploy.test.ts | 82 +++++++++++++++++++++++++---- 3 files changed, 124 insertions(+), 31 deletions(-) diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index 9abc875d..c688e756 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -39,10 +39,7 @@ export class DeployAction extends BaseAction { this.startSpinner(`Transpiling TypeScript file: ${filePath}`); try { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "genlayer-deploy-")); - const outFile = path.join( - tempDir, - path.basename(filePath).replace(/\.ts$/, ".compiled.js"), - ); + const outFile = path.join(tempDir, path.basename(filePath).replace(/\.ts$/, ".compiled.js")); buildSync({ entryPoints: [filePath], outfile: outFile, @@ -188,6 +185,12 @@ export class DeployAction extends BaseAction { // testnet (result.txDataDecoded as any)?.contractAddress; + if (!contractAddress) { + throw new Error( + `Deployment transaction ${String(hash)} reached consensus (${transactionConsensusStatus(result)}), but no contract address was returned. The contract may have failed to initialize.`, + ); + } + this.succeedSpinner("Contract deployed successfully.", { "Transaction Hash": hash, "Contract Address": contractAddress, diff --git a/src/commands/contracts/execution.ts b/src/commands/contracts/execution.ts index d9956139..99eb435c 100644 --- a/src/commands/contracts/execution.ts +++ b/src/commands/contracts/execution.ts @@ -22,7 +22,8 @@ function normalizeExecutionResult(value: unknown): ExecutionResult | undefined { if (normalized === ExecutionResult.DETERMINISTIC_VIOLATION) { return ExecutionResult.DETERMINISTIC_VIOLATION; } - if (normalized === "NONDET_DISAGREE" || normalized === "NONDET_DISAGREEMENT") 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)); @@ -58,9 +59,10 @@ function receiptData(receipt: unknown): { firstLeaderReceipt: unknown; genvmResult: unknown; } { - const record = receipt && typeof receipt === "object" && !Array.isArray(receipt) - ? receipt as Record - : {}; + const record = + receipt && typeof receipt === "object" && !Array.isArray(receipt) + ? (receipt as Record) + : {}; const data = directField(record, ["data"]); const consensusData = directField(data, ["consensus_data", "consensusData"]) ?? @@ -113,8 +115,10 @@ function consensusDiagnosis(status: TransactionStatus | undefined): string { } function executionDiagnosis(result: ExecutionResult | undefined): string { - if (result === ExecutionResult.TIMEOUT) return "TIMEOUT (leader timed out during execution)"; - if (result === ExecutionResult.NONDET_DISAGREE) { + if (result === "TIMEOUT" || result === (ExecutionResult as any).TIMEOUT) { + return "TIMEOUT (leader timed out during execution)"; + } + if (result === "NONDET_DISAGREE" || result === (ExecutionResult as any).NONDET_DISAGREE) { return "NONDET_DISAGREE (validators disagreed on non-deterministic output)"; } if (result === ExecutionResult.DETERMINISTIC_VIOLATION) { @@ -123,30 +127,52 @@ function executionDiagnosis(result: ExecutionResult | undefined): string { return result ?? "UNKNOWN"; } -export function assertSuccessfulExecution( - operation: string, - hash: unknown, - receipt: unknown, -): void { +export function extractExecutionErrorPayload(receipt: unknown): string | undefined { + const {firstLeaderReceipt, genvmResult} = receiptData(receipt); + const rawStderr = directField(genvmResult, ["stderr"]); + if (typeof rawStderr === "string" && rawStderr.trim()) { + return rawStderr.trim(); + } + const rawResult = directField(firstLeaderReceipt, ["result", "genvm_result"]); + if (typeof rawResult === "string" && rawResult.trim()) { + try { + const decoded = Buffer.from(rawResult, "base64").toString("utf-8"); + const cleaned = decoded.replace(/^[\x00-\x1F]+/, "").trim(); + if (cleaned) return cleaned; + } catch { + return rawResult.trim(); + } + } + return undefined; +} + +export function assertSuccessfulExecution(operation: string, hash: unknown, receipt: unknown): void { const status = transactionConsensusStatus(receipt); const result = transactionExecutionResult(receipt); - if (isSuccessful(receipt as any) || isSuccessful({ - ...(receipt && typeof receipt === "object" && !Array.isArray(receipt) ? receipt as Record : {}), - statusName: status, - txExecutionResultName: result, - } as any)) { + 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); + const errorPayload = extractExecutionErrorPayload(receipt); + const payloadSuffix = errorPayload ? ` (payload: ${errorPayload})` : ""; if (result === undefined) { throw new Error( - `${operation} ${String(hash)} transaction was decided as ${decidedAs}; leader execution result: UNKNOWN.`, + `${operation} ${String(hash)} transaction was decided as ${decidedAs}; leader execution result: UNKNOWN${payloadSuffix}.`, ); } throw new Error( - `${operation} ${String(hash)} transaction was decided as ${decidedAs}; leader execution result: ${executionDiagnosis(result)}.`, + `${operation} ${String(hash)} transaction was decided as ${decidedAs}; leader execution result: ${executionDiagnosis(result)}${payloadSuffix}.`, ); } diff --git a/tests/actions/deploy.test.ts b/tests/actions/deploy.test.ts index 9abfa195..af98545a 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -161,12 +161,14 @@ describe("DeployAction", () => { leaderTimeunitsAllocation: "10", rotations: ["0"], }, - messageAllocations: [{ - messageType: 1, - recipient: "0x0000000000000000000000000000000000000001", - callKey: DEPLOY_CALL_KEY, - budget: "5", - }], + messageAllocations: [ + { + messageType: 1, + recipient: "0x0000000000000000000000000000000000000001", + callKey: DEPLOY_CALL_KEY, + budget: "5", + }, + ], feeValue: "123", }, validUntil: "999", @@ -266,6 +268,70 @@ describe("DeployAction", () => { ); }); + test("fails when deployment consensus succeeds but execution returns error with invalid_contract payload", async () => { + const options: DeployOptions = { + contract: "/mocked/contract/path", + args: ["hello"], + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue( + "0x10520771987b0cf77665542282b80bbf9362f7cc86154b927fd45d8e806d9ae6", + ); + // Studio receipt shaped like in issue #430: status FINALIZED, execution_result ERROR, payload base64 invalid_contract + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + status: "FINALIZED", + statusName: "FINALIZED", + data: { + consensus_data: { + leader_receipt: [ + { + execution_result: "ERROR", + result: "AmludmFsaWRfY29udHJhY3Q=", + genvm_result: {stdout: "", stderr: ""}, + }, + ], + }, + }, + }); + + await deployer.deploy(options); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining( + "leader execution result: FINISHED_WITH_ERROR (payload: invalid_contract)", + ), + }), + ); + }); + + test("fails when deployment completes without contract address", async () => { + const options: DeployOptions = { + contract: "/mocked/contract/path", + }; + + 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", + txExecutionResultName: "FINISHED_WITH_RETURN", + data: {}, // no contract_address! + }); + + await deployer.deploy(options); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining("no contract address was returned"), + }), + ); + }); + test("fails when deployment is undetermined despite leader return", async () => { const options: DeployOptions = { contract: "/mocked/contract/path", @@ -737,9 +803,7 @@ describe("DeployAction", () => { }; // Lane B: getClient (BaseAction) builds the client itself. Stub the browser // session opener so the real getClient runs, then assert the wiring. - const getBrowserSessionSpy = vi - .spyOn(deployer as any, "getBrowserSession") - .mockResolvedValue(session); + const getBrowserSessionSpy = vi.spyOn(deployer as any, "getBrowserSession").mockResolvedValue(session); const getAccountSpy = vi.spyOn(deployer as any, "getAccount"); vi.mocked(fs.existsSync).mockReturnValue(true);