import fs from "fs"; import path from "path"; import { loadConfig, readJsonFile, writeJsonFile } from "./config"; import { promoteContractInfoCandidates, validateContractInfoCandidate, type ContractInfoCandidate, type ContractInfoCandidateReplacement, } from "./contractInfoCandidates"; import { loadContractInfo, validateContractInfo } from "./deployments"; import { loadDeploymentInfo, updateDeploymentInfoFromRecords } from "./deploymentInfo"; import { deploymentRecordPath, type DeploymentExecutionRecord } from "./deploymentRecords"; import { loadReleaseExecutionPlan } from "./executionPlan"; import { loadOperatorExecutionResult } from "./operatorExecution"; import { readParameterLockExecution } from "./lock"; import { hashValue } from "./release"; export interface RegistryCommitSelection { network: string; executionId: string; } export interface RegistryCommitReport { version: 1; status: "committed"; taskId: string; commitId: string; selections: RegistryCommitSelection[]; beforeHash: string; afterHash: string; changedEntries: Array<{ network: string; instanceId: string; previousKey?: string; previousAddress?: string; candidateKey: string; candidateAddress: string; }>; candidatePaths: string[]; sourceRecordPaths: string[]; backupPath?: string; deploymentInfoPath: string; deploymentInfoHash: string; completedAt: string; } const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,199}$/; const safeId = (value: string, label: string): string => { if (!SAFE_ID.test(value)) throw new Error(`${label} is invalid`); return value; }; const projectRelative = (root: string, filePath: string): string => { const relative = path.relative(path.resolve(root), path.resolve(filePath)); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { throw new Error("Registry evidence path escapes the project root"); } return relative.split(path.sep).join("/"); }; const writeExclusiveJson = (filePath: string, value: unknown): void => { fs.mkdirSync(path.dirname(filePath), { recursive: true }); try { fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o600, }); } catch (error) { if ( error && typeof error === "object" && "code" in error && String((error as { code?: unknown }).code) === "EEXIST" ) { throw new Error(`Registry evidence already exists: ${filePath}`); } throw error; } }; const loadContractInfoBackup = (filePath: string) => ( validateContractInfo(readJsonFile(filePath)) ); const candidatePath = (root: string, executionId: string): string => path.join( root, `contractInfo.pending.${safeId(executionId, "selection executionId")}.json`, ); const verificationPath = ( taskDir: string, selection: RegistryCommitSelection, ): string => path.join( taskDir, "results", selection.network, selection.executionId, "verify", "verification.json", ); const assertBusinessVerification = ( taskDir: string, taskId: string, selection: RegistryCommitSelection, ): void => { let verification: Record; try { verification = readJsonFile>( verificationPath(taskDir, selection), ); } catch { throw new Error(`Business verification is missing for ${selection.network}`); } if ( verification.taskId !== taskId || verification.target !== selection.network || verification.executionId !== selection.executionId || verification.mode !== "verify" || !Array.isArray(verification.steps) || verification.steps.length === 0 ) { throw new Error(`Business verification is missing for ${selection.network}`); } for (const [index, rawStep] of verification.steps.entries()) { if (!rawStep || typeof rawStep !== "object" || Array.isArray(rawStep)) { throw new Error(`Business verification step ${index} is invalid`); } const step = rawStep as Record; if (!step.results || typeof step.results !== "object" || Array.isArray(step.results)) { throw new Error(`Business verification step ${index} has no results`); } for (const result of Object.values(step.results as Record)) { if (!result || typeof result !== "object" || Array.isArray(result)) { throw new Error(`Business verification step ${index} result is invalid`); } const comparison = result as Record; if (String(comparison.actual) !== String(comparison.expected)) { throw new Error(`Business verification step ${index} did not match expected state`); } } } const status = readJsonFile>(path.join(taskDir, "status.json")); const networkStatus = status.networks && typeof status.networks === "object" ? (status.networks as Record>)[selection.network] : undefined; if ( networkStatus?.lastExecutionId !== selection.executionId || networkStatus.lastExecutionMode !== "verify" || networkStatus.verifyStatus !== "verified" ) { throw new Error(`Verify status is stale for ${selection.network}`); } }; const readRecord = ( taskDir: string, taskId: string, selection: RegistryCommitSelection, ): { record: DeploymentExecutionRecord; filePath: string } => { const filePath = deploymentRecordPath(taskDir, selection.network, selection.executionId); const record = readJsonFile(filePath); if ( record.taskId !== taskId || record.network !== selection.network || record.executionId !== selection.executionId ) { throw new Error(`Deployment record identity mismatch for ${selection.network}`); } if (record.status !== "waiting_code_check" && record.status !== "applied") { throw new Error( `Deployment record ${selection.network} is ${record.status}, expected waiting_code_check`, ); } return { record, filePath }; }; const readCandidate = ( root: string, taskId: string, selection: RegistryCommitSelection, allowPromoted: boolean, ): { candidate: ContractInfoCandidate; filePath: string } | undefined => { const filePath = candidatePath(root, selection.executionId); if (!fs.existsSync(filePath)) return undefined; const candidate = validateContractInfoCandidate(readJsonFile(filePath)); if ( candidate.deploymentId !== taskId || candidate.executionId !== selection.executionId || candidate.network !== selection.network ) { throw new Error(`ContractInfo candidate identity mismatch for ${selection.network}`); } if (candidate.status === "promoted" && !allowPromoted) { throw new Error(`ContractInfo candidate was already committed for ${selection.network}`); } return { candidate, filePath }; }; const reconciliationPath = ( taskDir: string, selection: RegistryCommitSelection, ): string => path.join( taskDir, "results", selection.network, selection.executionId, "external-reconciliation.json", ); const assertExecutionEvidence = (input: { root: string; taskDir: string; taskId: string; selection: RegistryCommitSelection; }): void => { const plan = loadReleaseExecutionPlan({ root: input.root, taskDir: input.taskDir, target: input.selection.network, executionId: input.selection.executionId, }); if (plan.taskId !== input.taskId) throw new Error("Execution plan taskId mismatch"); const operatorExecutionPath = path.join( input.taskDir, "results", plan.target, plan.executionId, "operator-execution.json", ); if ( plan.transactions.some((transaction) => transaction.route === "operator") || fs.existsSync(operatorExecutionPath) ) { loadOperatorExecutionResult({ taskDir: input.taskDir, plan }); } if (plan.releaseKind === "development") { if (plan.transactions.some((transaction) => transaction.route !== "operator")) { throw new Error("Development registry commit cannot contain external transactions"); } return; } const lock = readParameterLockExecution(input.taskDir, input.selection.executionId); if (lock.finalSimulation.status !== "succeeded") { throw new Error(`Final simulation is incomplete for ${input.selection.network}`); } if (lock.finalSimulation.executionPlanHash !== plan.planHash) { throw new Error(`Final simulation execution plan is stale for ${input.selection.network}`); } const report = readJsonFile>( reconciliationPath(input.taskDir, input.selection), ); if ( report.version !== 1 || report.taskId !== input.taskId || report.target !== input.selection.network || report.executionId !== input.selection.executionId || report.executionPlanHash !== plan.planHash || (report.status !== "not_required" && report.status !== "reconciled") || !Array.isArray(report.matches) || report.matches.length !== plan.transactions.length || report.matches.some((match) => ( !match || typeof match !== "object" || (match as Record).finalized !== true || (match as Record).receiptStatus !== 1 )) ) { throw new Error(`Admin external reconciliation is incomplete for ${input.selection.network}`); } }; const updateCommittedStatus = ( taskDir: string, selections: RegistryCommitSelection[], ): void => { const filePath = path.join(taskDir, "status.json"); const current = readJsonFile>(filePath); const currentNetworks = current.networks && typeof current.networks === "object" ? current.networks as Record> : {}; const networks = { ...currentNetworks }; const now = new Date().toISOString(); for (const selection of selections) { networks[selection.network] = { ...(networks[selection.network] || {}), deployStatus: "deployed_pending_code_audit", registryExecutionId: selection.executionId, updatedAt: now, }; } writeJsonFile(filePath, { ...current, status: "deployed_pending_code_audit", networks, updatedAt: now, }); }; const markDeploymentRecordsCommitted = ( root: string, recordPaths: string[], ): void => { const now = new Date().toISOString(); for (const recordPath of recordPaths) { const filePath = path.join(root, ...recordPath.split("/")); const record = readJsonFile(filePath); const changes = record.changes.map((change) => ( change.status === "waiting_code_check" ? { ...change, status: "applied" as const } : change )); if (changes.some((change) => change.status !== "applied")) { throw new Error(`Deployment record is not ready to commit: ${recordPath}`); } writeJsonFile(filePath, { ...record, status: "applied", changes, updatedAt: now, } satisfies DeploymentExecutionRecord); } }; const validateSelections = ( selections: RegistryCommitSelection[], ): RegistryCommitSelection[] => { if (selections.length === 0) throw new Error("Registry commit requires at least one selection"); const normalized = selections.map((selection) => ({ network: safeId(selection.network, "selection network"), executionId: safeId(selection.executionId, "selection executionId"), })).sort((left, right) => left.network.localeCompare(right.network)); if (new Set(normalized.map((selection) => selection.network)).size !== normalized.length) { throw new Error("Registry commit selections contain a duplicate network"); } return normalized; }; const loadExistingReport = ( filePath: string, taskId: string, commitId: string, selections: RegistryCommitSelection[], ): RegistryCommitReport | undefined => { if (!fs.existsSync(filePath)) return undefined; const report = readJsonFile(filePath); if ( report.version !== 1 || report.status !== "committed" || report.taskId !== taskId || report.commitId !== commitId || JSON.stringify(report.selections) !== JSON.stringify(selections) ) { throw new Error("Registry commitId already exists with different content"); } return report; }; export const commitVerifiedContractInfo = (input: { root: string; taskId: string; selections: RegistryCommitSelection[]; commitId: string; }): RegistryCommitReport => { const taskId = safeId(input.taskId, "registry taskId"); const commitId = safeId(input.commitId, "registry commitId"); const selections = validateSelections(input.selections); const taskDir = path.join(input.root, "scripts", "tasks", taskId); const registryDir = path.join(taskDir, "results", "registry"); const reportFile = path.join(registryDir, `${commitId}.json`); const backupFile = path.join(registryDir, `${commitId}.contractInfo.before.json`); const recoveringCommit = fs.existsSync(backupFile); const existingReport = loadExistingReport( reportFile, taskId, commitId, selections, ); if (existingReport) { const config = loadConfig(input.root); if (hashValue(loadContractInfo(input.root, config)) !== existingReport.afterHash) { throw new Error("Committed contractInfo hash no longer matches the registry report"); } if (hashValue(loadDeploymentInfo(input.root)) !== existingReport.deploymentInfoHash) { throw new Error("Committed deploymentInfo hash no longer matches the registry report"); } return existingReport; } const records: string[] = []; const candidates: Array<{ candidate: ContractInfoCandidate; filePath: string }> = []; for (const selection of selections) { assertExecutionEvidence({ root: input.root, taskDir, taskId, selection }); assertBusinessVerification(taskDir, taskId, selection); const record = readRecord(taskDir, taskId, selection); records.push(projectRelative(input.root, record.filePath)); const candidate = readCandidate(input.root, taskId, selection, recoveringCommit); if (candidate) candidates.push(candidate); } const config = loadConfig(input.root); const current = loadContractInfo(input.root, config); const before = recoveringCommit ? loadContractInfoBackup(backupFile) : current; const beforeHash = hashValue(before); let afterHash = beforeHash; let changedEntries: ContractInfoCandidateReplacement[] = []; let deploymentInfoHash = hashValue(loadDeploymentInfo(input.root)); let backupPath: string | undefined; if (candidates.length > 0) { if (!recoveringCommit) { writeExclusiveJson(backupFile, before); } backupPath = projectRelative(input.root, backupFile); const promoted = promoteContractInfoCandidates({ root: input.root, config, candidatePaths: candidates.map((candidate) => projectRelative(input.root, candidate.filePath)), }); afterHash = promoted.afterHash; changedEntries = promoted.changedEntries; } const deploymentInfo = updateDeploymentInfoFromRecords({ root: input.root, records: records.map((recordPath) => ({ recordPath })), expectedContractInfo: loadContractInfo(input.root, config), }); deploymentInfoHash = hashValue(deploymentInfo); markDeploymentRecordsCommitted(input.root, records); updateCommittedStatus(taskDir, selections); const report: RegistryCommitReport = { version: 1, status: "committed", taskId, commitId, selections, beforeHash, afterHash, changedEntries: [...changedEntries].sort((left, right) => ( `${left.network}.${left.instanceId}`.localeCompare(`${right.network}.${right.instanceId}`) )), candidatePaths: candidates .map((candidate) => projectRelative(input.root, candidate.filePath)) .sort(), sourceRecordPaths: [...records].sort(), backupPath, deploymentInfoPath: "deploymentInfo.json", deploymentInfoHash, completedAt: new Date().toISOString(), }; writeExclusiveJson(reportFile, report); return report; };