feat: add versioning utilities and helpers

Add TypeScript utilities: getContractVersion(), getVersionsMatrix(),
validateVersionChecksum(), getAnvilRpcUrl(), getDependencyVersions().
Integrate version validation into deployment flow.
This commit is contained in:
Gonza Montiel 2026-02-09 16:25:26 -03:00
parent 815036624b
commit 5c1fde6ff2
7 changed files with 419 additions and 8 deletions

View file

@ -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<string> => {
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<string, string> = {};
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);
}

View file

@ -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
);

48
test/utils/anvil.ts Normal file
View file

@ -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<string> => {
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<string> => {
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";
}
};

View file

@ -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<Deploym
throw new Error(`Error reading ${networkId} deployments file`);
}
const deploymentsJson = await deploymentsFile.json();
logger.info(`Deployments: ${JSON.stringify(deploymentsJson, null, 2)}`);
logger.debug(`Deployments: ${JSON.stringify(deploymentsJson, null, 2)}`);
try {
const parsedDeployments = DeploymentsSchema.parse(deploymentsJson);
logger.debug(`Successfully parsed ${networkId} deployments file.`);
@ -70,6 +91,7 @@ const abiMap = {
Gateway: generated.gatewayAbi,
ServiceManager: generated.dataHavenServiceManagerAbi,
ServiceManagerImplementation: generated.dataHavenServiceManagerAbi,
RewardsAgent: generated.agentAbi,
DelegationManager: generated.delegationManagerAbi,
StrategyManager: generated.strategyManagerAbi,
AVSDirectory: generated.avsDirectoryAbi,
@ -81,7 +103,10 @@ const abiMap = {
ETHPOSDeposit: generated.iethposDepositAbi,
BaseStrategyImplementation: generated.strategyBaseTvlLimitsAbi,
DeployedStrategies: erc20Abi
} as const satisfies Record<keyof Omit<Deployments, "network">, Abi>;
} as const satisfies Record<
keyof Omit<Deployments, "network" | "ProxyAdmin" | "version" | "deps">,
Abi
>;
type ContractName = keyof typeof abiMap;
type AbiFor<C extends ContractName> = (typeof abiMap)[C];
@ -95,6 +120,10 @@ export const getContractInstance = async <C extends ContractName>(
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}`);

View file

@ -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<ContractVersionCheckResult> => {
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<boolean> => {
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;
};

View file

@ -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<DependencyInfo> => {
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<DependencyVersions> => {
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;
}
};

View file

@ -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";