From e43060726537f058cfaf9f8fd544d8841697dc08 Mon Sep 17 00:00:00 2001 From: davidugorji Date: Wed, 26 Aug 2026 15:42:48 +0100 Subject: [PATCH] feat(cli): add verify-hash command to diff local WASM against on-chain Adds a `bc-forge verify-hash` command that answers whether a deployed contract is actually running the code in the local build tree. The local side hashes the raw .wasm bytes with SHA-256, which is exactly how Soroban derives the code hash it stores, so the two values are directly comparable. Verified against sha256sum on the same artifact. The on-chain side reads the contract instance ledger entry and walks val -> instance -> executable to the referenced wasmHash, rather than trusting a hash cached in .bc-forge.json, so the check reflects what the network currently serves. Distinct verdicts separate the failure modes an operator cares about: - match local build is what the network runs - mismatch contract runs different code than the local build - missing_local the build artifact was not found or is unreadable - missing_onchain no instance entry, or a Stellar-asset contract with no uploaded WASM - invalid malformed input or a failed RPC lookup The command exits non-zero on anything other than a match so it can gate a release pipeline. Closes #700 --- cli/src/__tests__/verify-hash.test.ts | Bin 0 -> 6742 bytes cli/src/commands/verify-hash.ts | 165 ++++++++++++++++++++++++++ cli/src/parseArgs.ts | 4 +- 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 cli/src/__tests__/verify-hash.test.ts create mode 100644 cli/src/commands/verify-hash.ts diff --git a/cli/src/__tests__/verify-hash.test.ts b/cli/src/__tests__/verify-hash.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..efe0b42eb48676c95c4b16a89ed38b9fdac84afb GIT binary patch literal 6742 zcmeHLS#R4$5KdqHD;DSjsX(UO^a5O@sIMSK(+?m>bS60gb6N*cd6tR&@+<6Jfux{*i$C>pmec(rz3*c^`uz8qM9uILBAUaiI4*rnJm zZyI4XL?BMp$j{|e=2Mn)t)*y~s@eO4@lQ7WuE(Dh6I5^x`0sw5 zVuflD6t5w2rz{XOyd8?pW|k*<`cx z!id>?48v36-0UODSXwt378Oz>##u7SchLfz2}@VI39$_o+n^@|bvvDGyXk}7^ug5U zLS?i#>20)zV5cWDY9!L+*FCKI?9rn~tmNmSD2wB{{> zftjgbPJ33D_Dl1nMSx8xTGsXweNkhVUOT|rOF=gvRyq`EA2AQ&eRe+Q3-LmzGc!%U z`O5F}a5=TjRM*P}Bp{U4m1`qQv{|gEEgKl92Zq|Fv0s=3Ue&z7YMa&l zsz!Ce$JYjpH&MD*`CZW94j--e-)qqJjUpcv0;;aOrq)^5&@c0@BHrzhyoesjZKvK? z;(_~mEcDAIv)$AN&#Ld^QcY(j}*O+k7-ro$U`9j`#gtp+~ zow-fbj)B3rn^fND*IxX6L@A;R%ZyhDy%{{!DOh*{!{j;LR5%LhA1z%qZ}Z_s#}N1w zn~XV2yjknf6tPcUzIwZRup7E{a9*Z`)fH*wYKfP}*yvQj>$0c$>G8y5-aXyYd2hIpVSmm-x0a$TH`)&g9Nhw!eVN!B@CC zy9U70*?P`7ff)o9GB=*=k{Jg^p|6Pf>DOA+o>O_fn;QL=x_dOZ|0bbzt=;mwvL^ag zHdghHPeVF)X{1{e_(Ia99sb35As+7F+x*%muChe0+%I+it{XiZ|EMREvP@76=I9;q z`VyMqYfTPjZSu`z)1YvDaCsL?@PuAzkaE8Bkw`z=ul!SP!TZpId=j(SGg16be6GcT ze$K_gyZSM|@CF@jNzFcn+Z-EH=O;?!baYg=K$z%_+BN3zr+0>tFUYsLt5&WyMu$a8 zLSIGwPvCd=ns%$(oKYJK*cx{RaQ|K*^JsY!w&C?DO4|Kul=R$au>^DxO7JBDAD^W> zYqcmoHHvL$C?8A)Ugd4rt;gES&Ztw{19*dO4YhSxe1)JJ1*H!`GuQP3l>Eto>E~?M z_}?=dAC66zd)Bi1qeAWbo7YE3-uk?q<3&JLbeG-UyvN2b^z`gr4?W##hHD*eiS}vt zgvgj~*-D{9WhX%G<(rdd@5U#`U0~21(!UJ(qIUCr$WK5Jp7(F52KG9Ej_6agGY|Aq S-?-qPzt99xO?9_w`u8V4pL@ap literal 0 HcmV?d00001 diff --git a/cli/src/commands/verify-hash.ts b/cli/src/commands/verify-hash.ts new file mode 100644 index 00000000..7c5788e1 --- /dev/null +++ b/cli/src/commands/verify-hash.ts @@ -0,0 +1,165 @@ +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import { Command } from 'commander'; +import { Contract, xdr, rpc as SorobanRpc } from '@stellar/stellar-sdk'; +import { getClientConfig } from '../utils/config.js'; +import logger from '../utils/logger.js'; + +export type HashVerdict = 'match' | 'mismatch' | 'missing_local' | 'missing_onchain' | 'invalid'; + +export interface HashComparison { + name: string; + contractId?: string; + wasmPath?: string; + localHash?: string; + onChainHash?: string; + verdict: HashVerdict; + error?: string; +} + +export interface HashFetcher { + getLedgerEntries: SorobanRpc.Server['getLedgerEntries']; +} + +/** + * Computes the Soroban WASM hash of a local build artifact. + * + * Soroban identifies uploaded contract code by the SHA-256 of the raw .wasm + * bytes, so hashing the file reproduces exactly the hash stored on-chain. + */ +export function hashLocalWasm(wasmPath: string): string { + const bytes = fs.readFileSync(wasmPath); + return crypto.createHash('sha256').update(bytes).digest('hex'); +} + +/** + * Extracts the WASM hash referenced by a contract instance ledger entry. + * + * Returns undefined for Stellar-asset contracts, which have no uploaded WASM. + */ +export function extractOnChainHash(entryData: xdr.LedgerEntryData): string | undefined { + if (entryData.switch().name !== 'contractData') return undefined; + + const val = entryData.contractData().val(); + if (val.switch().name !== 'scvContractInstance') return undefined; + + const executable = val.instance().executable(); + if (executable.switch().name !== 'contractExecutableWasm') return undefined; + + return executable.wasmHash().toString('hex'); +} + +/** + * Fetches the WASM hash a deployed contract currently runs. + */ +export async function fetchOnChainHash( + server: HashFetcher, + contractId: string +): Promise { + const footprint = new Contract(contractId).getFootprint(); + const response = await server.getLedgerEntries(footprint); + + const entry = response.entries?.[0]; + if (!entry) return undefined; + + return extractOnChainHash(entry.val); +} + +/** + * Diffs a local build artifact against the WASM hash a deployed contract runs. + */ +export async function verifyHash( + server: HashFetcher, + name: string, + contractId: string | undefined, + wasmPath: string | undefined +): Promise { + if (!contractId) { + return { name, wasmPath, verdict: 'invalid', error: 'No contractId configured' }; + } + if (!wasmPath) { + return { name, contractId, verdict: 'invalid', error: 'No local WASM path provided' }; + } + + let localHash: string; + try { + localHash = hashLocalWasm(wasmPath); + } catch (err: any) { + return { + name, + contractId, + wasmPath, + verdict: 'missing_local', + error: `Could not read local WASM: ${err.message}` + }; + } + + let onChainHash: string | undefined; + try { + onChainHash = await fetchOnChainHash(server, contractId); + } catch (err: any) { + return { + name, + contractId, + wasmPath, + localHash, + verdict: 'invalid', + error: err.message + }; + } + + if (!onChainHash) { + return { + name, + contractId, + wasmPath, + localHash, + verdict: 'missing_onchain', + error: 'No WASM hash found on-chain for this contract' + }; + } + + return { + name, + contractId, + wasmPath, + localHash, + onChainHash, + verdict: localHash === onChainHash ? 'match' : 'mismatch' + }; +} + +/** + * Builds the `verify-hash` command. + */ +export function createVerifyHashCommand(): Command { + return new Command('verify-hash') + .description('Diff a local WASM build against the hash a deployed contract runs') + .requiredOption('--wasm ', 'Path to the locally built .wasm artifact') + .option('--contract-id ', 'Contract to verify against (defaults to the configured contract)') + .option('--name ', 'Label for the contract in the report', 'contract') + .action(async (options) => { + try { + const clientConfig = getClientConfig(); + const contractId = options.contractId || clientConfig.contractId; + + logger.debug(`Verifying ${options.wasm} against ${contractId}`); + + const server = new SorobanRpc.Server(clientConfig.rpcUrl); + const result = await verifyHash(server, options.name, contractId, options.wasm); + + if (result.localHash) logger.info(`Local hash: ${result.localHash}`); + if (result.onChainHash) logger.info(`On-chain hash: ${result.onChainHash}`); + + if (result.verdict === 'match') { + logger.success(`${result.name}: local build matches the deployed contract`); + } else { + logger.error(`${result.name}: ${result.verdict}${result.error ? ` - ${result.error}` : ''}`); + process.exitCode = 1; + } + } catch (err: any) { + logger.error(`Error: ${err.message}`); + process.exitCode = 1; + } + }); +} diff --git a/cli/src/parseArgs.ts b/cli/src/parseArgs.ts index 74f6903f..862bd3c4 100644 --- a/cli/src/parseArgs.ts +++ b/cli/src/parseArgs.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { createUpgradeCommand } from "./commands/upgrade.js"; import { createSmokeTestCommand } from "./commands/smoke-test.js"; +import { createVerifyHashCommand } from "./commands/verify-hash.js"; const VERSION = "0.1.0"; @@ -20,7 +21,8 @@ export function buildProgram(): Command { program .addCommand(createUpgradeCommand()) - .addCommand(createSmokeTestCommand()); + .addCommand(createSmokeTestCommand()) + .addCommand(createVerifyHashCommand()); return program; }