From 5c1fde6ff2dfb11b1ba94a14f3a4771a278f8dc6 Mon Sep 17 00:00:00 2001 From: Gonza Montiel Date: Mon, 9 Feb 2026 16:25:26 -0300 Subject: [PATCH] feat: add versioning utilities and helpers Add TypeScript utilities: getContractVersion(), getVersionsMatrix(), validateVersionChecksum(), getAnvilRpcUrl(), getDependencyVersions(). Integrate version validation into deployment flow. --- test/scripts/deploy-contracts.ts | 78 ++++++++- test/scripts/set-datahaven-parameters.ts | 2 +- test/utils/anvil.ts | 48 ++++++ test/utils/contracts.ts | 33 +++- test/utils/contracts/versioning.ts | 205 +++++++++++++++++++++++ test/utils/dependencyVersions.ts | 59 +++++++ test/utils/index.ts | 2 + 7 files changed, 419 insertions(+), 8 deletions(-) create mode 100644 test/utils/anvil.ts create mode 100644 test/utils/contracts/versioning.ts create mode 100644 test/utils/dependencyVersions.ts diff --git a/test/scripts/deploy-contracts.ts b/test/scripts/deploy-contracts.ts index 22dffd06..93c0cf8c 100644 --- a/test/scripts/deploy-contracts.ts +++ b/test/scripts/deploy-contracts.ts @@ -1,3 +1,5 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; import { $ } from "bun"; import { CHAIN_CONFIGS, loadChainConfig } from "configs/contracts/config"; import invariant from "tiny-invariant"; @@ -45,6 +47,7 @@ export const validateDeploymentParams = (options: ContractDeploymentOptions) => */ export const buildContracts = async () => { logger.info("đŸ›ŗī¸ Building contracts..."); + const { exitCode: buildExitCode, stderr: buildStderr, @@ -116,6 +119,59 @@ export const executeDeployment = async ( logger.success("Contracts deployed successfully"); }; +/** + * Gets the current code version from contracts/VERSION file + * This is the single source of truth for the code version + */ +export const getCurrentVersion = async (): Promise => { + const cwd = process.cwd(); + const repoRoot = path.basename(cwd) === "test" ? path.join(cwd, "..") : cwd; + const versionFile = path.join(repoRoot, "contracts", "VERSION"); + + try { + const version = readFileSync(versionFile, "utf8").trim(); + if (!version) { + throw new Error("VERSION file is empty"); + } + return version; + } catch (error) { + throw new Error(`Failed to read contracts/VERSION: ${error}`); + } +}; + +/** + * Updates versions-matrix.json to track deployment + * Does NOT bump version - version comes from contracts/VERSION file + */ +export const updateDeploymentTracking = async (chain: string | undefined, version: string) => { + if (!chain) { + return; + } + + try { + const cwd = process.cwd(); + const repoRoot = path.basename(cwd) === "test" ? path.join(cwd, "..") : cwd; + const matrixFile = path.join(repoRoot, "contracts", "versions-matrix.json"); + + const matrixContent = readFileSync(matrixFile, "utf8"); + const matrix = JSON.parse(matrixContent); + + // Update deployment info + if (!matrix.deployments) { + matrix.deployments = {}; + } + matrix.deployments[chain] = { + version, + lastDeployed: new Date().toISOString() + }; + + writeFileSync(matrixFile, JSON.stringify(matrix, null, 2)); + logger.info(`📝 Updated versions-matrix.json: ${chain} deployed at version ${version}`); + } catch (error) { + logger.warn(`âš ī¸ Could not update versions-matrix.json: ${error}`); + } +}; + /** * Read the parameters from the deployed contracts and add it to the collection. */ @@ -224,16 +280,25 @@ export const deployContracts = async (options: { // Build contracts await buildContracts(); + // Get current code version from VERSION file (single source of truth) + const currentVersion = await getCurrentVersion(); + logger.info(`📌 Deploying code version: ${currentVersion}`); + // Construct and execute deployment const deployCommand = constructDeployCommand(deploymentOptions); - const env = buildDeploymentEnv(deploymentOptions); + const env = buildDeploymentEnv(deploymentOptions, currentVersion); await executeDeployment(deployCommand, undefined, networkId, env); if (!txExecutionEnabled) { await emitOwnerTransactionCalldata(networkId); } - logger.success(`DataHaven contracts deployed successfully to ${networkId}`); + // Update versions-matrix.json to track this deployment + await updateDeploymentTracking(options.chain, currentVersion); + + logger.success( + `DataHaven contracts deployed successfully to ${options.chain} at version ${currentVersion}` + ); }; const normalizePrivateKey = (key?: string): `0x${string}` | undefined => { @@ -243,7 +308,7 @@ const normalizePrivateKey = (key?: string): `0x${string}` | undefined => { return (key.startsWith("0x") ? key : `0x${key}`) as `0x${string}`; }; -const buildDeploymentEnv = (options: ContractDeploymentOptions) => { +const buildDeploymentEnv = (options: ContractDeploymentOptions, version: string) => { const env: Record = {}; if (options.privateKey) { @@ -262,6 +327,9 @@ const buildDeploymentEnv = (options: ContractDeploymentOptions) => { env.TX_EXECUTION = options.txExecution ? "true" : "false"; } + // Pass version to Solidity scripts for contract initialization + env.DATAHAVEN_VERSION = version; + return env; }; @@ -337,12 +405,12 @@ if (import.meta.main) { } if (!options.rpcUrl) { - console.error("Error: --rpc-url parameter is required"); + logger.error("Error: --rpc-url parameter is required"); process.exit(1); } if (options.verified && !options.blockscoutBackendUrl) { - console.error("Error: --blockscout-url parameter is required when using --verified"); + logger.error("Error: --blockscout-url parameter is required when using --verified"); process.exit(1); } diff --git a/test/scripts/set-datahaven-parameters.ts b/test/scripts/set-datahaven-parameters.ts index 13b127d5..83885e02 100644 --- a/test/scripts/set-datahaven-parameters.ts +++ b/test/scripts/set-datahaven-parameters.ts @@ -38,7 +38,7 @@ export const setDataHavenParameters = async ( dhApi.tx.Parameters.set_parameter({ key_value: { type: "RuntimeConfig", - value: { type: p.name, value: [p.value] } + value: { type: p.name as any, value: [p.value] } } }).decodedCall ); diff --git a/test/utils/anvil.ts b/test/utils/anvil.ts new file mode 100644 index 00000000..29ae6aa4 --- /dev/null +++ b/test/utils/anvil.ts @@ -0,0 +1,48 @@ +import { getPortFromKurtosis } from "./kurtosis"; +import { logger } from "./logger"; + +/** + * Gets the RPC URL for the Anvil (local Ethereum) node running in Kurtosis + * @param enclaveName - The name of the Kurtosis enclave (default: "datahaven-ethereum") + * @returns The HTTP RPC URL for the Ethereum node + */ +export const getAnvilRpcUrl = async (enclaveName = "datahaven-ethereum"): Promise => { + try { + logger.debug("Getting Anvil RPC URL from Kurtosis..."); + + // Get the RPC port from the EL (Execution Layer) service + const rpcPort = await getPortFromKurtosis("el-1-reth-lodestar", "rpc", enclaveName); + + const rpcUrl = `http://127.0.0.1:${rpcPort}`; + logger.debug(`Anvil RPC URL: ${rpcUrl}`); + + return rpcUrl; + } catch (error) { + logger.warn(`âš ī¸ Failed to get Anvil RPC URL from Kurtosis: ${error}`); + logger.warn(" Falling back to default http://localhost:8545"); + return "http://localhost:8545"; + } +}; + +/** + * Gets the WebSocket URL for the Anvil (local Ethereum) node running in Kurtosis + * @param enclaveName - The name of the Kurtosis enclave (default: "datahaven-ethereum") + * @returns The WebSocket URL for the Ethereum node + */ +export const getAnvilWsUrl = async (enclaveName = "datahaven-ethereum"): Promise => { + try { + logger.debug("Getting Anvil WebSocket URL from Kurtosis..."); + + // Get the WS port from the EL (Execution Layer) service + const wsPort = await getPortFromKurtosis("el-1-reth-lodestar", "ws", enclaveName); + + const wsUrl = `ws://127.0.0.1:${wsPort}`; + logger.debug(`Anvil WebSocket URL: ${wsUrl}`); + + return wsUrl; + } catch (error) { + logger.warn(`âš ī¸ Failed to get Anvil WebSocket URL from Kurtosis: ${error}`); + logger.warn(" Falling back to default ws://localhost:8546"); + return "ws://localhost:8546"; + } +}; diff --git a/test/utils/contracts.ts b/test/utils/contracts.ts index cc55dee8..278fc536 100644 --- a/test/utils/contracts.ts +++ b/test/utils/contracts.ts @@ -11,6 +11,7 @@ const ethAddressCustom = z.custom<`0x${string}`>( (val) => typeof val === "string" && ethAddressRegex.test(val), { message: "Invalid Ethereum address" } ); + const DeployedStrategySchema = z.object({ address: ethAddress, underlyingToken: ethAddress, @@ -24,6 +25,7 @@ const DeploymentsSchema = z.object({ Gateway: ethAddressCustom, ServiceManager: ethAddressCustom, ServiceManagerImplementation: ethAddressCustom, + RewardsAgent: ethAddressCustom, DelegationManager: ethAddressCustom, StrategyManager: ethAddressCustom, AVSDirectory: ethAddressCustom, @@ -34,6 +36,25 @@ const DeploymentsSchema = z.object({ PermissionController: ethAddressCustom, ETHPOSDeposit: ethAddressCustom.optional(), BaseStrategyImplementation: ethAddressCustom.optional(), + ProxyAdmin: ethAddressCustom.optional(), + // Version tag for this set of deployed contracts (optional for backwards compatibility) + version: z.string().optional(), + deps: z + .object({ + eigenlayer: z + .object({ + release: z.string().optional(), + gitCommit: z.string().optional() + }) + .optional(), + snowbridge: z + .object({ + release: z.string().optional(), + gitCommit: z.string().optional() + }) + .optional() + }) + .optional(), DeployedStrategies: z.array(DeployedStrategySchema).optional() }); @@ -52,7 +73,7 @@ export const parseDeploymentsFile = async (networkId = "anvil"): Promise, Abi>; +} as const satisfies Record< + keyof Omit, + Abi +>; type ContractName = keyof typeof abiMap; type AbiFor = (typeof abiMap)[C]; @@ -95,6 +120,10 @@ export const getContractInstance = async ( viemClient?: ViemClientInterface, network = "anvil" ) => { + invariant( + contract !== "DeployedStrategies", + "getContractInstance does not support 'DeployedStrategies' as it is an array. Use a different method to access deployed strategies." + ); const deployments = await parseDeploymentsFile(network); const contractAddress = deployments[contract]; logger.debug(`Contract ${contract} deployed to ${contractAddress}`); diff --git a/test/utils/contracts/versioning.ts b/test/utils/contracts/versioning.ts new file mode 100644 index 00000000..52bf5e12 --- /dev/null +++ b/test/utils/contracts/versioning.ts @@ -0,0 +1,205 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { CHAIN_CONFIGS } from "configs/contracts/config"; +import { logger } from "utils"; +import { getContractInstance } from "utils/contracts"; +import type { ViemClientInterface } from "utils/viem"; +import { createWalletClient, defineChain, http, publicActions } from "viem"; + +export interface ContractVersionCheckResult { + ok: boolean; + skipped: boolean; +} + +const assertValidChain = (chain: string) => { + const supportedChains = ["hoodi", "ethereum", "anvil"]; + if (!supportedChains.includes(chain)) { + throw new Error(`Unsupported chain: ${chain}. Supported chains: ${supportedChains.join(", ")}`); + } +}; + +const isInfraUnavailableError = (error: unknown): boolean => { + const message = + error instanceof Error ? error.message : typeof error === "string" ? error : String(error); + + return ( + message.includes("Failed to connect to Docker daemon") || + (message.includes("container") && + message.includes("cannot be found in running container list")) || + message.includes("ECONNREFUSED") || + message.includes("ECONNRESET") || + message.includes("ENOTFOUND") || + message.includes("EHOSTUNREACH") || + message.includes("Was there a typo in the url or port?") + ); +}; + +export const checkContractVersions = async ( + chain: string, + rpcUrl?: string +): Promise => { + assertValidChain(chain); + logger.info(`🔍 Checking contract versions for chain '${chain}'`); + + // Read version from versions-matrix.json + const cwd = process.cwd(); + const repoRoot = path.basename(cwd) === "test" ? path.join(cwd, "..") : cwd; + const matrixFile = path.join(repoRoot, "contracts", "versions-matrix.json"); + + let version: string | undefined; + try { + const matrixContent = readFileSync(matrixFile, "utf8"); + const matrix = JSON.parse(matrixContent); + version = matrix.deployments?.[chain]?.version; + } catch (_error) { + logger.info( + "â„šī¸ Could not read versions-matrix.json - skipping version check (probably fresh deployment)" + ); + return { ok: true, skipped: true }; + } + + if (!version) { + logger.info( + `â„šī¸ No version tracked for '${chain}' in versions-matrix.json - skipping version check (probably fresh deployment)` + ); + return { ok: true, skipped: true }; + } + + let viemClient: ViemClientInterface | undefined; + const chainConfig = CHAIN_CONFIGS[chain as keyof typeof CHAIN_CONFIGS]; + if (chainConfig && chain !== "anvil") { + const chainDef = defineChain({ + id: chainConfig.CHAIN_ID, + name: chainConfig.NETWORK_NAME, + nativeCurrency: { + name: "Ether", + symbol: "ETH", + decimals: 18 + }, + rpcUrls: { + default: { + http: [rpcUrl ?? chainConfig.RPC_URL] + } + }, + blockExplorers: chainConfig.BLOCK_EXPLORER + ? { + default: { name: "Explorer", url: chainConfig.BLOCK_EXPLORER } + } + : undefined + }); + + viemClient = createWalletClient({ + chain: chainDef, + transport: http() + }).extend(publicActions) as unknown as ViemClientInterface; + } + + let ok = true; + + try { + const serviceManager: any = await getContractInstance("ServiceManager", viemClient, chain); + const smVersion: string = await serviceManager.read.DATAHAVEN_VERSION(); + + if (smVersion !== version) { + logger.error( + `❌ DataHavenServiceManager DATAHAVEN_VERSION=${smVersion} does not match deployments version=${version} for chain='${chain}'.` + ); + ok = false; + } else { + logger.info( + `✅ DataHavenServiceManager version matches deployments version (${version}) for chain='${chain}'.` + ); + } + } catch (error) { + if (isInfraUnavailableError(error)) { + logger.warn( + `âš ī¸ Skipping on-chain version checks for chain='${chain}': no local Ethereum node or containers detected (${error}).` + ); + return { ok: true, skipped: true }; + } + const errorMsg = String(error); + + // Check if function doesn't exist (old deployment without version tracking) + if ( + errorMsg.includes("DATAHAVEN_VERSION") && + (errorMsg.includes("returned no data") || errorMsg.includes("does not have the function")) + ) { + logger.warn( + `âš ī¸ ServiceManager at ${chain} does not have DATAHAVEN_VERSION() function yet (old deployment). Skipping on-chain version check.` + ); + return { ok: true, skipped: true }; + } + + if ( + errorMsg.includes("DATAHAVEN_VERSION") && + (errorMsg.includes("reverted") || errorMsg.includes("missing revert data")) + ) { + throw new Error( + `ServiceManager at ${chain} does not expose DATAHAVEN_VERSION() yet. ` + + "This usually means the on-chain implementation is older than the versioning update. " + + "Upgrade the ServiceManager implementation, then re-run the check." + ); + } + throw new Error(`Failed to read version from DataHavenServiceManager: ${error}`); + } + + if (!ok) { + return { ok: false, skipped: false }; + } + + logger.info( + `✅ All checked contract versions match deployments version=${version} on '${chain}'.` + ); + return { ok: true, skipped: false }; +}; + +/** + * Validates that a version string follows semantic versioning (X.Y.Z) + */ +export const isValidSemver = (version: string): boolean => { + const semverRegex = /^\d+\.\d+\.\d+$/; + return semverRegex.test(version); +}; + +/** + * Validates version formats across all deployment files + */ +export const validateVersionFormats = async (): Promise => { + const cwd = process.cwd(); + const repoRoot = path.basename(cwd) === "test" ? path.join(cwd, "..") : cwd; + const matrixFile = path.join(repoRoot, "contracts", "versions-matrix.json"); + + let allValid = true; + + try { + const matrixContent = readFileSync(matrixFile, "utf8"); + const matrix = JSON.parse(matrixContent); + const codeVersion = matrix?.code?.version; + + if (!codeVersion) { + logger.warn("âš ī¸ versions-matrix.json has no code.version"); + allValid = false; + } else if (!isValidSemver(codeVersion)) { + logger.error(`❌ Invalid code.version format in versions-matrix.json: ${codeVersion}`); + allValid = false; + } + + const deployments = matrix?.deployments ?? {}; + for (const [chain, entry] of Object.entries(deployments)) { + const version = (entry as { version?: string }).version; + if (!version) { + logger.warn(`âš ī¸ No version for '${chain}' in versions-matrix.json deployments`); + continue; + } + if (!isValidSemver(version)) { + logger.error(`❌ Invalid deployment version format for '${chain}': ${version}`); + allValid = false; + } + } + } catch (error) { + logger.warn(`âš ī¸ Could not read versions-matrix.json: ${error}`); + return false; + } + + return allValid; +}; diff --git a/test/utils/dependencyVersions.ts b/test/utils/dependencyVersions.ts new file mode 100644 index 00000000..7a13d045 --- /dev/null +++ b/test/utils/dependencyVersions.ts @@ -0,0 +1,59 @@ +import path from "node:path"; +import { $ } from "bun"; +import { logger } from "./logger"; + +export interface DependencyInfo { + release?: string; + gitCommit: string; +} + +export interface DependencyVersions { + eigenlayer: DependencyInfo; + snowbridge: DependencyInfo; +} + +const resolveRepoRoot = (): string => { + const cwd = process.cwd(); + const base = path.basename(cwd); + return base === "test" ? path.join(cwd, "..") : cwd; +}; + +const getGitInfo = async (relativePath: string): Promise => { + const repoRoot = resolveRepoRoot(); + const fullPath = path.join(repoRoot, relativePath); + + const { stdout: shaOut, exitCode: shaCode } = await $`git -C ${fullPath} rev-parse HEAD` + .nothrow() + .quiet(); + + if (shaCode !== 0) { + throw new Error(`Failed to resolve git commit for ${relativePath}`); + } + + const gitCommit = shaOut.toString().trim(); + + const { stdout: tagOut, exitCode: tagCode } = + await $`git -C ${fullPath} describe --tags --exact-match`.nothrow().quiet(); + + const release = tagCode === 0 ? tagOut.toString().trim() : undefined; + + return { gitCommit, release }; +}; + +export const getDependencyVersions = async (): Promise => { + try { + const [eigenlayer, snowbridge] = await Promise.all([ + getGitInfo(path.join("contracts", "lib", "eigenlayer-contracts")), + getGitInfo(path.join("contracts", "lib", "snowbridge")) + ]); + + logger.info( + `Derived dependency versions: eigenlayer=${eigenlayer.release ?? eigenlayer.gitCommit}, snowbridge=${snowbridge.release ?? snowbridge.gitCommit}` + ); + + return { eigenlayer, snowbridge }; + } catch (error) { + logger.error(`Failed to derive dependency versions from git: ${error}`); + throw error; + } +}; diff --git a/test/utils/index.ts b/test/utils/index.ts index 2868c943..819af502 100644 --- a/test/utils/index.ts +++ b/test/utils/index.ts @@ -1,6 +1,8 @@ +export * from "./anvil"; export * from "./blockscout"; export * from "./constants"; export * from "./contracts"; +export * from "./contracts/versioning"; export * from "./docker"; export * from "./events"; export * from "./input";