import fs from "fs"; import path from "path"; import type { WorkflowConfig } from "./config"; import { readJsonFile, writeJsonFile } from "./config"; import { cloneContractInfo, ContractInfo, loadContractInfo, planDeploymentAddress, resolveDeployment, saveContractInfo, validateContractInfo, withContractInfoLock, } from "./deployments"; import { validateExecutionId } from "./executionId"; import { prepareDeploymentInfoFromRecords, saveDeploymentInfo, type DeploymentRecordPromotionInput, } from "./deploymentInfo"; import { hashValue } from "./release"; export interface ContractInfoCandidateReplacement { network: string; instanceId: string; previousKey?: string; previousAddress?: string; candidateKey: string; candidateAddress: string; } export interface ContractInfoCandidate { version: 1; status: "pending" | "promoted"; deploymentId: string; executionId: string; network: string; baseContractInfoHash: string; backupPath: string; replacements: ContractInfoCandidateReplacement[]; contractInfo: ContractInfo; createdAt: string; updatedAt: string; promotedAt?: string; promotedContractInfoHash?: string; } export interface StageContractInfoCandidateOptions { root: string; taskDir: string; config: WorkflowConfig; deploymentId: string; executionId: string; network: string; } export interface StageContractInfoReplacement { instanceId: string; contractName: string; address: string; } export interface StagedContractInfoCandidate { candidate: ContractInfoCandidate; candidatePath: string; backupPath: string; replacement: ContractInfoCandidateReplacement; } export interface PromoteContractInfoCandidatesOptions { root: string; config: WorkflowConfig; candidatePaths: string[]; } export interface PromotedContractInfoCandidates { contractInfo: ContractInfo; beforeHash: string; afterHash: string; deploymentInfoPath: string; deploymentInfoHash: string; changedEntries: ContractInfoCandidateReplacement[]; promoted: Array<{ candidatePath: string; candidate: ContractInfoCandidate; }>; } export interface PreviewedContractInfoCandidates { contractInfo: ContractInfo; candidates: Array<{ candidatePath: string; candidate: ContractInfoCandidate; }>; } export interface FindExecutionCandidateAddressOptions { root: string; deploymentId: string; executionId: string; network: string; name: string; } const isRecord = (value: unknown): value is Record => { return !!value && typeof value === "object" && !Array.isArray(value); }; const safeNetworkFileId = (value: string): string => { const normalized = value.trim(); if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(normalized)) { throw new Error("network must contain only letters, numbers, underscores, or hyphens"); } return normalized; }; const relativeProjectPath = (root: string, absolutePath: string): string => { const relative = path.relative(root, absolutePath); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { throw new Error("Candidate path must stay inside the project root: " + absolutePath); } return relative.split(path.sep).join("/"); }; const resolveProjectPath = (root: string, filePath: string): string => { const absolute = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(root, filePath); const relative = path.relative(path.resolve(root), absolute); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { throw new Error("Candidate path must stay inside the project root: " + filePath); } return absolute; }; const writeExclusiveJson = (filePath: string, value: unknown): void => { fs.mkdirSync(path.dirname(filePath), { recursive: true }); let file: number; try { file = fs.openSync(filePath, "wx"); } catch (error) { if (isRecord(error) && String(error.code) === "EEXIST") { throw new Error("Refusing to overwrite existing contractInfo backup: " + filePath); } throw error; } try { fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n"); fs.fsyncSync(file); } catch (error) { fs.closeSync(file); fs.unlinkSync(filePath); throw error; } fs.closeSync(file); }; const validateReplacement = ( value: unknown, label: string, ): ContractInfoCandidateReplacement => { if (!isRecord(value)) throw new Error(label + " must be an object"); for (const key of ["network", "instanceId", "candidateKey", "candidateAddress"] as const) { if (typeof value[key] !== "string" || !String(value[key]).trim()) { throw new Error(label + "." + key + " must be a non-empty string"); } } return value as unknown as ContractInfoCandidateReplacement; }; export const validateContractInfoCandidate = (value: unknown): ContractInfoCandidate => { if (!isRecord(value)) throw new Error("contractInfo candidate must be an object"); if (value.version !== 1) throw new Error("contractInfo candidate version must be 1"); if (value.status !== "pending" && value.status !== "promoted") { throw new Error("contractInfo candidate status must be pending or promoted"); } for (const key of [ "deploymentId", "network", "baseContractInfoHash", "backupPath", "createdAt", "updatedAt", ] as const) { if (typeof value[key] !== "string" || !String(value[key]).trim()) { throw new Error("contractInfo candidate " + key + " must be a non-empty string"); } } validateExecutionId(value.executionId, "contractInfo candidate executionId"); if (!Array.isArray(value.replacements)) { throw new Error("contractInfo candidate replacements must be an array"); } return { ...(value as unknown as ContractInfoCandidate), replacements: value.replacements.map((item, index) => ( validateReplacement(item, "contractInfo candidate replacements[" + index + "]") )), contractInfo: validateContractInfo(value.contractInfo), }; }; const candidateRelativePath = (executionId: string): string => { return "contractInfo.pending." + validateExecutionId(executionId) + ".json"; }; const backupAbsolutePath = ( options: StageContractInfoCandidateOptions, ): string => { const network = safeNetworkFileId(options.network); const executionId = validateExecutionId(options.executionId); return path.join( options.taskDir, "results", network + "." + executionId + ".contractInfo.before.json", ); }; const readCandidate = (filePath: string): ContractInfoCandidate => { return validateContractInfoCandidate(readJsonFile(filePath)); }; export const findExecutionCandidateAddress = ( options: FindExecutionCandidateAddressOptions, ): string | undefined => { const executionId = validateExecutionId(options.executionId); const filePath = path.join(options.root, candidateRelativePath(executionId)); if (!fs.existsSync(filePath)) return undefined; const candidate = readCandidate(filePath); if ( candidate.deploymentId !== options.deploymentId || candidate.executionId !== executionId || candidate.network !== options.network ) { throw new Error("contractInfo candidate does not match this verification execution"); } const deployment = resolveDeployment(candidate.contractInfo, options.network, options.name); if (!deployment) return undefined; const replacements = candidate.replacements.filter((replacement) => ( replacement.network === options.network && replacement.candidateKey === deployment.key && replacement.candidateAddress.toLowerCase() === deployment.address.toLowerCase() )); if (replacements.length > 1) { throw new Error( "contractInfo candidate contains duplicate replacements for " + options.network + "." + options.name, ); } return replacements.length === 1 ? deployment.address : undefined; }; const pendingCandidateFiles = (root: string): string[] => { if (!fs.existsSync(root)) return []; return fs.readdirSync(root) .filter((name) => { const prefix = "contractInfo.pending."; const suffix = ".json"; if (!name.startsWith(prefix) || !name.endsWith(suffix)) return false; try { validateExecutionId(name.slice(prefix.length, -suffix.length)); return true; } catch { return false; } }) .map((name) => path.join(root, name)); }; const assertNoPendingConflict = ( root: string, candidatePath: string, network: string, instanceId: string, ): void => { for (const filePath of pendingCandidateFiles(root)) { if (path.resolve(filePath) === path.resolve(candidatePath)) continue; const candidate = readCandidate(filePath); if (candidate.status !== "pending") continue; const conflict = candidate.replacements.some((replacement) => ( replacement.network === network && replacement.instanceId === instanceId )); if (conflict) { throw new Error( "pending candidate conflict for " + network + "." + instanceId + " in " + path.basename(filePath), ); } } }; const replacementFor = ( contractInfo: ContractInfo, network: string, replacement: StageContractInfoReplacement, ): { replacement: ContractInfoCandidateReplacement; contractInfo: ContractInfo; } => { const previous = resolveDeployment(contractInfo, network, replacement.instanceId); const planned = planDeploymentAddress( contractInfo, network, replacement.instanceId, replacement.contractName, replacement.address, ); return { replacement: { network, instanceId: replacement.instanceId, previousKey: previous?.key, previousAddress: previous?.address, candidateKey: planned.key, candidateAddress: replacement.address, }, contractInfo: planned.contractInfo, }; }; export const stageContractInfoCandidate = ( options: StageContractInfoCandidateOptions, replacementInput: StageContractInfoReplacement, ): StagedContractInfoCandidate => { validateExecutionId(options.executionId); return withContractInfoLock(options.root, () => { const candidatePath = candidateRelativePath(options.executionId); const candidateAbsolute = path.join(options.root, candidatePath); const backupAbsolute = backupAbsolutePath(options); const backupPath = relativeProjectPath(options.root, backupAbsolute); assertNoPendingConflict( options.root, candidateAbsolute, options.network, replacementInput.instanceId, ); if (fs.existsSync(candidateAbsolute)) { const existing = readCandidate(candidateAbsolute); if ( existing.status !== "pending" || existing.deploymentId !== options.deploymentId || existing.executionId !== options.executionId || existing.network !== options.network || existing.backupPath !== backupPath ) { throw new Error("Existing contractInfo candidate does not match this execution"); } const duplicate = existing.replacements.find((item) => ( item.network === options.network && item.instanceId === replacementInput.instanceId )); if (duplicate) { if ( duplicate.candidateAddress.toLowerCase() !== replacementInput.address.toLowerCase() || duplicate.candidateKey !== planDeploymentAddress( existing.contractInfo, options.network, replacementInput.instanceId, replacementInput.contractName, replacementInput.address, ).key ) { throw new Error( "Execution candidate already defines a different replacement for " + options.network + "." + replacementInput.instanceId, ); } return { candidate: existing, candidatePath, backupPath, replacement: duplicate, }; } const next = replacementFor(existing.contractInfo, options.network, replacementInput); const updated: ContractInfoCandidate = { ...existing, replacements: [...existing.replacements, next.replacement], contractInfo: next.contractInfo, updatedAt: new Date().toISOString(), }; writeJsonFile(candidateAbsolute, updated); return { candidate: updated, candidatePath, backupPath, replacement: next.replacement, }; } const current = cloneContractInfo(loadContractInfo(options.root, options.config)); const next = replacementFor(current, options.network, replacementInput); writeExclusiveJson(backupAbsolute, current); const now = new Date().toISOString(); const candidate: ContractInfoCandidate = { version: 1, status: "pending", deploymentId: options.deploymentId, executionId: options.executionId, network: options.network, baseContractInfoHash: hashValue(current), backupPath, replacements: [next.replacement], contractInfo: next.contractInfo, createdAt: now, updatedAt: now, }; writeJsonFile(candidateAbsolute, candidate); return { candidate, candidatePath, backupPath, replacement: next.replacement, }; }); }; const sameDeployment = ( left: { key: string; address: string } | undefined, rightKey: string | undefined, rightAddress: string | undefined, ): boolean => { if (!left || !rightKey || !rightAddress) { return left === undefined && rightKey === undefined && rightAddress === undefined; } return left.key === rightKey && left.address.toLowerCase() === rightAddress.toLowerCase(); }; const updateDeploymentRecordAfterPromotion = ( root: string, candidate: ContractInfoCandidate, contractInfo: ContractInfo, ): void => { const resultDir = path.dirname(resolveProjectPath(root, candidate.backupPath)); const recordPath = path.join( resultDir, candidate.network + "." + candidate.executionId + ".deployment.json", ); if (!fs.existsSync(recordPath)) return; const record = readJsonFile>(recordPath); const changes = Array.isArray(record.changes) ? record.changes.map((change) => { if (!isRecord(change)) return change; const promoted = candidate.replacements.some((replacement) => ( replacement.instanceId === change.instanceId && replacement.candidateAddress.toLowerCase() === String(change.candidateAddress || change.address).toLowerCase() )); return promoted ? { ...change, status: "applied" } : change; }) : []; const allApplied = changes.every((change) => isRecord(change) && change.status === "applied"); writeJsonFile(recordPath, { ...record, status: allApplied ? "applied" : record.status, candidateStatus: "promoted", promotedAt: new Date().toISOString(), contractInfo: { ...(isRecord(record.contractInfo) ? record.contractInfo : {}), after: contractInfo, }, changes, updatedAt: new Date().toISOString(), }); }; const promotionRecord = ( root: string, candidate: ContractInfoCandidate, ): DeploymentRecordPromotionInput => { const resultDir = path.dirname(resolveProjectPath(root, candidate.backupPath)); const absolutePath = path.join( resultDir, `${candidate.network}.${candidate.executionId}.deployment.json`, ); return { recordPath: relativeProjectPath(root, absolutePath) }; }; const prepareCandidatePromotion = ( options: PromoteContractInfoCandidatesOptions, ): { contractInfo: ContractInfo; selected: Array<{ candidatePath: string; absolutePath: string; candidate: ContractInfoCandidate; backup: ContractInfo; }>; } => { const uniquePaths = [...new Set(options.candidatePaths)]; if (uniquePaths.length !== options.candidatePaths.length) { throw new Error("Duplicate contractInfo candidate path in promotion request"); } const selected = uniquePaths.map((candidatePath) => { const absolutePath = resolveProjectPath(options.root, candidatePath); const candidate = readCandidate(absolutePath); if ( candidate.status === "promoted" && candidate.promotedContractInfoHash !== hashValue(candidate.contractInfo) ) { throw new Error("promoted contractInfo candidate hash is invalid: " + candidatePath); } const backup = validateContractInfo( readJsonFile(resolveProjectPath(options.root, candidate.backupPath)), ); if (hashValue(backup) !== candidate.baseContractInfoHash) { throw new Error("contractInfo candidate backup hash changed: " + candidatePath); } return { candidatePath, absolutePath, candidate, backup }; }); const current = cloneContractInfo(loadContractInfo(options.root, options.config)); let merged = cloneContractInfo(current); const selectedInstances = new Set(); for (const item of selected) { for (const replacement of item.candidate.replacements) { const instanceKey = replacement.network + "." + replacement.instanceId; if (selectedInstances.has(instanceKey)) { throw new Error("Multiple selected candidates replace " + instanceKey); } selectedInstances.add(instanceKey); const backupDeployment = resolveDeployment( item.backup, replacement.network, replacement.instanceId, ); if (!sameDeployment( backupDeployment, replacement.previousKey, replacement.previousAddress, )) { throw new Error("candidate backup does not match replacement metadata for " + instanceKey); } const candidateDeployment = resolveDeployment( item.candidate.contractInfo, replacement.network, replacement.instanceId, ); if (!sameDeployment( candidateDeployment, replacement.candidateKey, replacement.candidateAddress, )) { throw new Error("candidate registry does not match replacement metadata for " + instanceKey); } const currentDeployment = resolveDeployment( current, replacement.network, replacement.instanceId, ); const currentMatchesBase = sameDeployment( currentDeployment, replacement.previousKey, replacement.previousAddress, ); const currentMatchesCandidate = sameDeployment( currentDeployment, replacement.candidateKey, replacement.candidateAddress, ); if (!currentMatchesBase && !currentMatchesCandidate) { throw new Error("candidate base conflict for " + instanceKey); } merged = planDeploymentAddress( merged, replacement.network, replacement.instanceId, replacement.candidateKey.split(":").at(-1) || replacement.candidateKey, replacement.candidateAddress, ).contractInfo; } } return { contractInfo: merged, selected }; }; export const previewContractInfoCandidates = ( options: PromoteContractInfoCandidatesOptions, ): PreviewedContractInfoCandidates => { return withContractInfoLock(options.root, () => { const prepared = prepareCandidatePromotion(options); return { contractInfo: prepared.contractInfo, candidates: prepared.selected.map((item) => ({ candidatePath: item.candidatePath, candidate: item.candidate, })), }; }); }; export const promoteContractInfoCandidates = ( options: PromoteContractInfoCandidatesOptions, ): PromotedContractInfoCandidates => { if (options.candidatePaths.length === 0) { throw new Error("At least one contractInfo candidate is required for promotion"); } return withContractInfoLock(options.root, () => { const prepared = prepareCandidatePromotion(options); const merged = prepared.contractInfo; const selected = prepared.selected; const beforeHash = hashValue(loadContractInfo(options.root, options.config)); const records = selected.map((item) => promotionRecord(options.root, item.candidate)); const deploymentInfo = prepareDeploymentInfoFromRecords({ root: options.root, records, expectedContractInfo: merged, }); saveContractInfo(options.root, merged); saveDeploymentInfo(options.root, deploymentInfo); const promotedAt = new Date().toISOString(); const promotedHash = hashValue(merged); const promoted = selected.map((item) => { const candidate: ContractInfoCandidate = { ...item.candidate, status: "promoted", promotedAt, promotedContractInfoHash: promotedHash, updatedAt: promotedAt, }; writeJsonFile(item.absolutePath, candidate); updateDeploymentRecordAfterPromotion(options.root, candidate, merged); return { candidatePath: item.candidatePath, candidate }; }); return { contractInfo: merged, beforeHash, afterHash: promotedHash, deploymentInfoPath: "deploymentInfo.json", deploymentInfoHash: hashValue(deploymentInfo), changedEntries: selected.flatMap((item) => item.candidate.replacements), promoted, }; }); };