import fs from "fs"; import path from "path"; import { getAddress, isAddress } from "ethers"; import { readJsonFile, writeJsonFile } from "./config"; import type { ContractInfo } from "./deployments"; import { resolveDeployment, validateContractInfo } from "./deployments"; export interface DeploymentInfoEntry { contractInfoKey: string; address: string; actualContract: string; proxyKind?: "uups" | "transparent"; implementationAddress?: string; source: DeploymentInfoSource; updatedAt: string; } export type DeploymentInfoSource = { kind: "deployment"; deploymentId: string; executionId: string; recordPath: string; } | { kind: "migration"; migrationId: string; evidencePath: string; }; export interface DeploymentInfo { version: 1; networks: Record>; } export interface DeploymentRecordPromotionInput { recordPath: string; } const isRecord = (value: unknown): value is Record => ( Boolean(value) && typeof value === "object" && !Array.isArray(value) ); const safeId = (value: unknown, label: string): string => { if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(value)) { throw new Error(`${label} must be a safe identifier`); } return value; }; const address = (value: unknown, label: string): string => { if (typeof value !== "string" || !isAddress(value)) { throw new Error(`${label} must be a valid EVM address`); } return getAddress(value); }; const optionalAddress = (value: unknown, label: string): string | undefined => ( value === undefined ? undefined : address(value, label) ); const deploymentInfoPath = (root: string): string => path.join(root, "deploymentInfo.json"); const validateSource = (value: unknown, label: string): DeploymentInfoSource => { if (!isRecord(value)) throw new Error(`${label} is required`); const sourceKeys = Object.keys(value).sort(); if (value.kind === "migration") { if (sourceKeys.join(",") !== "evidencePath,kind,migrationId") { throw new Error(`${label} migration source must use the canonical fields`); } const migrationId = safeId(value.migrationId, `${label}.migrationId`); const evidencePath = String(value.evidencePath).split(path.sep).join("/"); const expectedPath = `scripts/migrations/${migrationId}/state-import.json`; if (evidencePath !== expectedPath) { throw new Error(`${label}.evidencePath must be ${expectedPath}`); } return { kind: "migration", migrationId, evidencePath }; } const legacyDeployment = value.kind === undefined && sourceKeys.join(",") === "deploymentId,executionId,recordPath"; const canonicalDeployment = value.kind === "deployment" && sourceKeys.join(",") === "deploymentId,executionId,kind,recordPath"; if (!legacyDeployment && !canonicalDeployment) { throw new Error(`${label} deployment source must use the canonical fields`); } return { kind: "deployment", deploymentId: safeId(value.deploymentId, `${label}.deploymentId`), executionId: safeId(value.executionId, `${label}.executionId`), recordPath: String(value.recordPath), }; }; const confinedRecordPath = (root: string, recordPath: string): string => { if (!recordPath || path.isAbsolute(recordPath)) { throw new Error(`Deployment record path must be project-relative: ${recordPath}`); } const absolute = path.resolve(root, recordPath); const relative = path.relative(path.resolve(root), absolute); if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { throw new Error(`Deployment record path escapes the project: ${recordPath}`); } const stat = fs.lstatSync(absolute); if (!stat.isFile() || stat.isSymbolicLink()) { throw new Error(`Deployment record must be a regular file: ${recordPath}`); } return absolute; }; const validateEntry = (value: unknown, label: string): DeploymentInfoEntry => { if (!isRecord(value)) throw new Error(`${label} must be an object`); const allowed = new Set([ "contractInfoKey", "address", "actualContract", "proxyKind", "implementationAddress", "source", "updatedAt", ]); if (Object.keys(value).some((key) => !allowed.has(key))) { throw new Error(`${label} contains unsupported fields`); } if (typeof value.contractInfoKey !== "string" || !value.contractInfoKey.trim()) { throw new Error(`${label}.contractInfoKey is required`); } if (typeof value.actualContract !== "string" || !value.actualContract.trim()) { throw new Error(`${label}.actualContract is required`); } if (value.proxyKind !== undefined && value.proxyKind !== "uups" && value.proxyKind !== "transparent") { throw new Error(`${label}.proxyKind is invalid`); } if (typeof value.updatedAt !== "string" || !Number.isFinite(Date.parse(value.updatedAt))) { throw new Error(`${label}.updatedAt must be an ISO timestamp`); } return { contractInfoKey: value.contractInfoKey, address: address(value.address, `${label}.address`), actualContract: value.actualContract, ...(value.proxyKind ? { proxyKind: value.proxyKind } : {}), ...(value.implementationAddress ? { implementationAddress: address(value.implementationAddress, `${label}.implementationAddress`) } : {}), source: validateSource(value.source, `${label}.source`), updatedAt: value.updatedAt, }; }; export const validateDeploymentInfo = (value: unknown): DeploymentInfo => { if (!isRecord(value) || value.version !== 1 || !isRecord(value.networks)) { throw new Error("deploymentInfo.json must contain version 1 network metadata"); } if (Object.keys(value).some((key) => key !== "version" && key !== "networks")) { throw new Error("deploymentInfo.json contains unsupported fields"); } const networks: DeploymentInfo["networks"] = {}; for (const [network, entries] of Object.entries(value.networks)) { safeId(network, "deploymentInfo network"); if (!isRecord(entries)) throw new Error(`deploymentInfo network ${network} must be an object`); networks[network] = Object.fromEntries(Object.entries(entries).map(([instanceId, entry]) => [ safeId(instanceId, `deploymentInfo ${network} instanceId`), validateEntry(entry, `deploymentInfo ${network}.${instanceId}`), ])); } return { version: 1, networks }; }; export function loadDeploymentInfo(root: string): DeploymentInfo { const filePath = deploymentInfoPath(root); if (!fs.existsSync(filePath)) return { version: 1, networks: {} }; return validateDeploymentInfo(readJsonFile(filePath)); } export function saveDeploymentInfo(root: string, value: DeploymentInfo): void { writeJsonFile(deploymentInfoPath(root), validateDeploymentInfo(value)); } export function prepareDeploymentInfoFromRecords(input: { root: string; records: DeploymentRecordPromotionInput[]; expectedContractInfo: ContractInfo; }): DeploymentInfo { const expectedContractInfo = validateContractInfo(input.expectedContractInfo); const current = loadDeploymentInfo(input.root); const networks = Object.fromEntries(Object.entries(current.networks).map(([network, entries]) => [ network, { ...entries }, ])); const seenPaths = new Set(); for (const inputRecord of input.records) { if (seenPaths.has(inputRecord.recordPath)) throw new Error(`Duplicate deployment record path: ${inputRecord.recordPath}`); seenPaths.add(inputRecord.recordPath); const absolutePath = confinedRecordPath(input.root, inputRecord.recordPath); const record = readJsonFile(absolutePath); if (!isRecord(record) || !Array.isArray(record.changes)) { throw new Error(`Deployment record is malformed: ${inputRecord.recordPath}`); } const network = safeId(record.network, "deployment record network"); const deploymentId = safeId(record.taskId, "deployment record taskId"); const executionId = safeId(record.executionId, "deployment record executionId"); const targetEntries = { ...(networks[network] ?? {}) }; for (const [index, rawChange] of record.changes.entries()) { if (!isRecord(rawChange)) throw new Error(`Deployment record change ${index} must be an object`); const instanceId = safeId(rawChange.instanceId, `deployment record change ${index} instanceId`); const actualContract = safeId(rawChange.actualContract, `deployment record change ${index} actualContract`); if (typeof rawChange.contractInfoKey !== "string" || !rawChange.contractInfoKey.trim()) { throw new Error(`Deployment record change ${index} contractInfoKey is required`); } const resolved = resolveDeployment(expectedContractInfo, network, instanceId); if (!resolved || resolved.key !== rawChange.contractInfoKey) { throw new Error(`Deployment metadata conflicts with contractInfo for ${network}.${instanceId}`); } const deployedAddress = address( rawChange.proxyAddress ?? rawChange.candidateAddress ?? rawChange.address, `deployment record change ${index} address`, ); if (resolved.address.toLowerCase() !== deployedAddress.toLowerCase()) { throw new Error(`Deployment metadata address conflicts with contractInfo for ${network}.${instanceId}`); } const existing = targetEntries[instanceId]; if ( existing && existing.address.toLowerCase() !== deployedAddress.toLowerCase() && optionalAddress(rawChange.previousAddress, `deployment record change ${index} previousAddress`)?.toLowerCase() !== existing.address.toLowerCase() ) { throw new Error(`Deployment metadata address conflict for ${network}.${instanceId}`); } const rawProxyKind = rawChange.proxyKind === undefined ? existing?.proxyKind : rawChange.proxyKind; if (rawProxyKind !== undefined && rawProxyKind !== "uups" && rawProxyKind !== "transparent") { throw new Error(`Deployment record change ${index} proxyKind is invalid`); } const proxyKind: DeploymentInfoEntry["proxyKind"] = rawProxyKind; const implementationAddress = rawChange.implementationAddress === undefined ? existing?.implementationAddress : optionalAddress(rawChange.implementationAddress, `deployment record change ${index} implementationAddress`); const nextEntry = { contractInfoKey: rawChange.contractInfoKey, address: deployedAddress, actualContract, ...(proxyKind ? { proxyKind } : {}), ...(implementationAddress ? { implementationAddress } : {}), source: { kind: "deployment" as const, deploymentId, executionId, recordPath: inputRecord.recordPath, }, }; const existingComparable = existing ? { contractInfoKey: existing.contractInfoKey, address: existing.address, actualContract: existing.actualContract, ...(existing.proxyKind ? { proxyKind: existing.proxyKind } : {}), ...(existing.implementationAddress ? { implementationAddress: existing.implementationAddress } : {}), source: existing.source, } : undefined; targetEntries[instanceId] = { ...nextEntry, updatedAt: existingComparable && JSON.stringify(existingComparable) === JSON.stringify(nextEntry) ? existing!.updatedAt : new Date().toISOString(), }; } networks[network] = targetEntries; } return validateDeploymentInfo({ version: 1, networks }); } export function updateDeploymentInfoFromRecords(input: { root: string; records: DeploymentRecordPromotionInput[]; expectedContractInfo: ContractInfo; }): DeploymentInfo { const next = prepareDeploymentInfoFromRecords(input); saveDeploymentInfo(input.root, next); return next; }