From 6927509e20a540fb342783e39faaa03e5052c8af Mon Sep 17 00:00:00 2001 From: Galmanus Date: Tue, 25 Aug 2026 07:55:01 -0300 Subject: [PATCH] fix(core): inspect compares against artifact wasmPath and surfaces CLI errors Bug 1: inspectContract resolved the local WASM from contract.wasmPath (the config build output, always the default/testnet path), ignoring the per-network artifact.wasmPath. `inspect --network mainnet` therefore hashed the testnet WASM against the mainnet artifact hash and always reported "Local WASM: differs or missing". Prefer artifact.wasmPath (resolved against cwd, since it is stored project-relative), falling back to the config path, and report the path actually used. Bug 2: the reachability probe wraps the Stellar CLI failure in a generic message and only forwards the original error as `cause`, so the CLI output was lost. Append the underlying CLI output (carried on the cause's hint) to onChain.detail so an unreachable result is diagnosable. The probe-command choice raised in the issue is left to the maintainer. Adds inspect-contract.test.ts covering both. Closes #133 --- .../src/contracts/inspect-contract.test.ts | 151 ++++++++++++++++++ .../core/src/contracts/inspect-contract.ts | 26 ++- 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/contracts/inspect-contract.test.ts diff --git a/packages/core/src/contracts/inspect-contract.test.ts b/packages/core/src/contracts/inspect-contract.test.ts new file mode 100644 index 00000000..f0b781ad --- /dev/null +++ b/packages/core/src/contracts/inspect-contract.test.ts @@ -0,0 +1,151 @@ +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CaatingaConfig } from "../config/config.schema.js"; +import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; +import { inspectContract } from "./inspect-contract.js"; + +const readArtifactsMock = vi.hoisted(() => vi.fn()); +const checkBinaryMock = vi.hoisted(() => vi.fn()); +const verifyDependencyContractMock = vi.hoisted(() => vi.fn()); +const resolveWasmArtifactPathMock = vi.hoisted(() => vi.fn()); +const hashWasmMock = vi.hoisted(() => vi.fn()); + +vi.mock("../artifacts/read-artifacts.js", () => ({ + readArtifacts: readArtifactsMock, +})); + +vi.mock("../shell/check-binary.js", () => ({ + checkBinary: checkBinaryMock, +})); + +vi.mock("./verify-dependency-contract.js", () => ({ + verifyDependencyContract: verifyDependencyContractMock, +})); + +vi.mock("./wasm.js", () => ({ + resolveWasmArtifactPath: resolveWasmArtifactPathMock, + hashWasm: hashWasmMock, +})); + +const CONTRACT_ID = "C".padEnd(56, "A"); +const ARTIFACT_HASH = "a".repeat(64); + +const config: CaatingaConfig = { + project: "yield-app", + defaultNetwork: "testnet", + contracts: { + yield_distributor: { + path: "./contracts/yield_distributor", + wasm: "./contracts/yield_distributor/target/wasm32v1-none/release/yield_distributor.wasm", + dependsOn: [], + deployArgs: {}, + }, + }, + networks: { + testnet: { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + }, + mainnet: { + rpcUrl: "https://mainnet.sorobanrpc.com", + networkPassphrase: "Public Global Stellar Network ; September 2015", + }, + }, + frontend: { + framework: "vite-react", + bindingsOutput: "./src/contracts/generated", + }, +}; + +const CWD = "/tmp/app"; +const MAINNET_WASM = "./deploy/mainnet-wasm/yield_distributor.wasm"; + +function mainnetArtifacts() { + return { + project: "yield-app", + version: 1 as const, + networks: { + mainnet: { + contracts: { + yield_distributor: { + contractId: CONTRACT_ID, + wasmHash: ARTIFACT_HASH, + deployedAt: "2026-01-01T00:00:00.000Z", + wasmPath: MAINNET_WASM, + sourcePath: "./contracts/yield_distributor", + dependencies: [], + resolvedDeployArgs: {}, + }, + }, + dependencyGraph: {}, + }, + }, + }; +} + +describe("inspectContract", () => { + beforeEach(() => { + readArtifactsMock.mockReset(); + checkBinaryMock.mockReset(); + checkBinaryMock.mockResolvedValue(undefined); + verifyDependencyContractMock.mockReset(); + verifyDependencyContractMock.mockResolvedValue(undefined); + resolveWasmArtifactPathMock.mockReset(); + hashWasmMock.mockReset(); + }); + + it("hashes the per-network artifact wasmPath, not the config wasm (#133 bug 1)", async () => { + readArtifactsMock.mockResolvedValue(mainnetArtifacts()); + resolveWasmArtifactPathMock.mockResolvedValue("/tmp/app/deploy/mainnet-wasm/yield_distributor.wasm"); + hashWasmMock.mockResolvedValue(ARTIFACT_HASH); + + const result = await inspectContract({ + config, + contractName: "yield_distributor", + networkName: "mainnet", + cwd: CWD, + }); + + // The local WASM source must be the mainnet artifact path resolved against + // cwd — never the testnet config build output. + expect(resolveWasmArtifactPathMock).toHaveBeenCalledWith( + path.resolve(CWD, MAINNET_WASM), + expect.anything() + ); + expect(result.localWasm.path).toBe(MAINNET_WASM); + expect(result.localWasm.hash).toBe(ARTIFACT_HASH); + expect(result.localWasm.matchesArtifact).toBe(true); + }); + + it("surfaces the underlying Stellar CLI output when the contract is unreachable (#133 bug 2)", async () => { + readArtifactsMock.mockResolvedValue(mainnetArtifacts()); + resolveWasmArtifactPathMock.mockResolvedValue("/tmp/app/deploy/mainnet-wasm/yield_distributor.wasm"); + hashWasmMock.mockResolvedValue(ARTIFACT_HASH); + + const cliFailure = new CaatingaError( + "Command failed: stellar contract info interface", + CaatingaErrorCode.DEPENDENCY_CONTRACT_NOT_FOUND, + "error: contract not found on network mainnet", + undefined + ); + verifyDependencyContractMock.mockRejectedValue( + new CaatingaError( + `Dependency "yield_distributor" is not deployed on "mainnet" (contract ID ${CONTRACT_ID}).`, + CaatingaErrorCode.DEPENDENCY_CONTRACT_NOT_FOUND, + "Deploy the dependency on this network.", + cliFailure + ) + ); + + const result = await inspectContract({ + config, + contractName: "yield_distributor", + networkName: "mainnet", + cwd: CWD, + }); + + expect(result.onChain.reachable).toBe(false); + expect(result.onChain.detail).toContain("is not deployed"); + expect(result.onChain.detail).toContain("Stellar CLI: error: contract not found on network mainnet"); + }); +}); diff --git a/packages/core/src/contracts/inspect-contract.ts b/packages/core/src/contracts/inspect-contract.ts index eb026a75..e68518a2 100644 --- a/packages/core/src/contracts/inspect-contract.ts +++ b/packages/core/src/contracts/inspect-contract.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { readArtifacts } from "../artifacts/read-artifacts.js"; import type { CaatingaConfig } from "../config/config.schema.js"; import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; @@ -69,11 +70,32 @@ export async function inspectContract( } catch (error) { reachable = false; detail = error instanceof CaatingaError ? error.message : "Contract not reachable on network."; + // #133 (bug 2): the reachability probe wraps the Stellar CLI failure in a + // generic message and only forwards the original error as `cause`. Surface + // that underlying CLI output (carried on the cause's hint) so an + // unreachable result is diagnosable instead of a bare "not reachable". + const cause = error instanceof CaatingaError ? error.cause : undefined; + const cliOutput = cause instanceof CaatingaError ? cause.hint?.trim() : undefined; + if (cliOutput) { + detail = `${detail}\nStellar CLI: ${cliOutput}`; + } } + // #133 (bug 1): prefer the per-network artifact wasmPath (e.g. a mainnet build + // under ./deploy/mainnet-wasm) over the config `wasm`, which always points at + // the default (testnet) build output. Comparing the mainnet artifact hash + // against the testnet WASM produced a false "Local WASM: differs or missing". + // artifact.wasmPath is stored relative to the project root, so resolve it + // against cwd (resolveWasmArtifactPath would otherwise resolve it against + // process.cwd()). + const localWasmSource = artifact.wasmPath + ? path.resolve(cwd, artifact.wasmPath) + : contract.wasmPath; + const localWasmDisplayPath = artifact.wasmPath ?? contract.config.wasm; + let localHash: string | undefined; try { - const wasmPath = await resolveWasmArtifactPath(contract.wasmPath, { + const wasmPath = await resolveWasmArtifactPath(localWasmSource, { sourcePath: contract.sourcePath, }); localHash = await hashWasm(wasmPath); @@ -92,7 +114,7 @@ export async function inspectContract( }, onChain: { reachable, detail }, localWasm: { - path: contract.config.wasm, + path: localWasmDisplayPath, hash: localHash, matchesArtifact: Boolean(localHash && localHash === artifact.wasmHash), },