datahaven/test/utils/contracts/versioning.ts
Gonza Montiel 7097767021
feat: contracts upgrade command (#463)
## Contracts upgrade command with simple version tracking

This PR aims to take the most minimal changes from #438 to make the
upgrade command available.
So it adds the `bun cli contracts upgrade` command for deploying a new
`DataHavenServiceManager` implementation and upgrading the proxy, and
includes a simple version tracking via a `contracts/VERSION` file.

### Contracts
**`DataHavenServiceManager.sol`**
- Added `_version` storage variable
- Added `DATAHAVEN_VERSION()` view function, 
- Added `updateVersion(string)` function gated by `onlyProxyAdmin`
- Added `VersionUpdated` event
- The version is set at initialization and updated atomically with proxy
upgrades via `upgradeAndCall`.
 
### CLI

**`bun cli contracts upgrade`** works in two modes: _dry-run_ or
_execute_.

**Dry-run (default)**

Deploys the new implementation on-chain (signed by the deployer key),
then prints a ready-to-submit JSON payload for the multisig to execute
the proxy upgrade. No AVS owner key required.

```bash
# Uses version from contracts/VERSION (standard workflow)
bun cli contracts upgrade --chain hoodi

# Override version for this upgrade only (warns if it differs from contracts/VERSION)
bun cli contracts upgrade --chain hoodi --target x.y.z
```

Example output:
```json
{
  "to": "0xProxyAdmin...",
  "value": "0",
  "data": "0x...",
  "description": "Upgrade ServiceManager proxy to 0xNewImpl... and set version to 1.1.0"
}
```

**Execute mode (`--execute`)**

Deploys the implementation and broadcasts the proxy upgrade + version
update in a single atomic `upgradeAndCall` transaction. Requires
`AVS_OWNER_PRIVATE_KEY`. Used mostly for testing.

```bash
  bun cli contracts upgrade --chain anvil --execute
```
---
### Expected flow
- Bump mannually contracts/VERSION (e.g., 1.1.0)
- Run bun cli contracts upgrade --chain anvil|hoodi|mainnet
2026-03-02 21:50:10 +01:00

180 lines
5.7 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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?")
);
};
/**
* Reads the expected version from contracts/VERSION file.
* Returns undefined if the file cannot be read.
*/
const readVersionFile = (): string | undefined => {
try {
const cwd = process.cwd();
const repoRoot = path.basename(cwd) === "test" ? path.join(cwd, "..") : cwd;
const versionFile = path.join(repoRoot, "contracts", "VERSION");
return readFileSync(versionFile, "utf8").trim();
} catch {
return undefined;
}
};
export const checkContractVersions = async (
chain: string,
rpcUrl?: string
): Promise<ContractVersionCheckResult> => {
assertValidChain(chain);
logger.info(`🔍 Checking contract versions for chain '${chain}'`);
// Read expected version from contracts/VERSION file
const version = readVersionFile();
if (!version) {
logger.info(
" Could not read contracts/VERSION - 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 contracts/VERSION=${version} for chain='${chain}'.`
);
ok = false;
} else {
logger.info(
`✅ DataHavenServiceManager version matches contracts/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 contracts/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 that contracts/VERSION contains a valid semver string
*/
export const validateVersionFormats = async (): Promise<boolean> => {
const version = readVersionFile();
if (!version) {
logger.warn("⚠️ Could not read contracts/VERSION");
return false;
}
if (!isValidSemver(version)) {
logger.error(`❌ Invalid version format in contracts/VERSION: ${version}`);
return false;
}
logger.info(`✅ contracts/VERSION is valid semver: ${version}`);
return true;
};