import crypto from "crypto"; import fs from "fs"; import path from "path"; import { getAddress, isAddress } from "ethers"; import { type ContractInfo, validateContractInfo, withContractInfoLock, } from "./deployments"; import { type DeploymentInfo, validateDeploymentInfo, } from "./deploymentInfo"; export type MigrationReleaseStage = "development" | "production"; export type MigrationProxyKind = "none" | "uups" | "transparent"; export interface MigrationContractPlan { instanceId: string; contractInfoKey: string; address: string; actualContract: string; proxyKind: MigrationProxyKind; implementationAddress?: string; } export interface MigrationNetworkPlan { sourceNetwork: string; releaseStage: MigrationReleaseStage; chainId: number; manifestDir: string; contracts: MigrationContractPlan[]; } export interface MigrationStatePlan { version: 1; migrationId: string; sourceProject: string; networks: Record; } export interface MigrationInspection { chainId: number; hasCode: boolean; artifactExists: boolean; proxyKind: MigrationProxyKind; implementationAddress?: string; address?: string; } export interface MigrationImportEvidence { version: 1; status: "imported"; integrityHash: string; migrationId: string; sourceProject: string; planHash: string; contractInfoHash: string; deploymentInfoHash: string; networks: Record; confirmations: string[]; bytecodeCheck: { status: "passed"; reportPath: string; reportHash: string; }; before: { contractInfoHash: string; deploymentInfoHash: string; }; importedAt: string; } type MigrationImportEvidencePayload = Omit; export interface MigrationTargetConfig { environment: string; openzeppelin?: { manifestDir?: unknown }; } export interface MigrationStateIo { writeJson?( filePath: string, value: unknown, options?: { exclusive?: boolean }, ): void; rename?(sourcePath: string, targetPath: string): void; afterRename?(sourcePath: string, targetPath: string, index: number): void; } export interface ImportMigrationStateOptions { root: string; plan: unknown; targets: Record; confirmations: string[]; inspect: ( network: string, contract: MigrationContractPlan, ) => Promise | MigrationInspection; verifyCandidate: ( contractInfo: ContractInfo, ) => Promise<{ status: "passed" | "failed"; report: unknown }>; now?: () => string; io?: MigrationStateIo; } const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,199}$/; const SAFE_MANIFEST_DIR = /^\.openzeppelin(?:\.[a-z0-9][a-z0-9-]*)?$/; const isRecord = (value: unknown): value is Record => ( Boolean(value) && typeof value === "object" && !Array.isArray(value) ); const fsErrorCode = (error: unknown): string | undefined => ( error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : undefined ); const lstatIfPresent = (filePath: string): fs.Stats | undefined => { try { return fs.lstatSync(filePath); } catch (error) { if (fsErrorCode(error) === "ENOENT") return undefined; throw error; } }; const exactKeys = ( value: Record, required: string[], optional: string[], label: string, ): void => { const keys = Object.keys(value); if ( required.some((key) => !keys.includes(key)) || keys.some((key) => !required.includes(key) && !optional.includes(key)) ) { throw new Error(`${label} is not canonical: unsupported or missing fields`); } }; const safeId = (value: unknown, label: string): string => { if (typeof value !== "string" || !SAFE_ID.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 normalizeForHash = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(normalizeForHash); if (isRecord(value)) { return Object.fromEntries(Object.keys(value).sort().map((key) => [ key, normalizeForHash(value[key]), ])); } return value; }; const hashValue = (value: unknown): string => crypto .createHash("sha256") .update(JSON.stringify(normalizeForHash(value))) .digest("hex"); const hashBuffer = (value: Buffer | undefined): string => crypto .createHash("sha256") .update(value ?? Buffer.from("")) .digest("hex"); const sha256 = (value: unknown, label: string): string => { if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) { throw new Error(`${label} must be a lowercase SHA-256 hash`); } return value; }; const nonEmptyString = (value: unknown, label: string): string => { if (typeof value !== "string" || !value.trim()) { throw new Error(`${label} is required`); } return value; }; const isoTimestamp = (value: unknown, label: string): string => { const timestamp = nonEmptyString(value, label); if (Number.isNaN(Date.parse(timestamp))) { throw new Error(`${label} must be an ISO timestamp`); } return timestamp; }; const validateContractPlan = ( value: unknown, label: string, ): MigrationContractPlan => { if (!isRecord(value)) throw new Error(`${label} must be an object`); exactKeys(value, [ "instanceId", "contractInfoKey", "address", "actualContract", "proxyKind", ], ["implementationAddress"], label); const instanceId = safeId(value.instanceId, `${label}.instanceId`); const actualContract = safeId(value.actualContract, `${label}.actualContract`); if (typeof value.contractInfoKey !== "string" || !value.contractInfoKey.trim()) { throw new Error(`${label}.contractInfoKey is required`); } const keyParts = value.contractInfoKey.split(":"); if ( keyParts.length > 2 || keyParts[0] !== instanceId || keyParts.at(-1) !== actualContract ) { throw new Error( `${label}.contractInfoKey must identify ${instanceId} and actual contract ${actualContract}`, ); } if ( value.proxyKind !== "none" && value.proxyKind !== "uups" && value.proxyKind !== "transparent" ) { throw new Error(`${label}.proxyKind is invalid`); } const implementationAddress = value.implementationAddress === undefined ? undefined : address(value.implementationAddress, `${label}.implementationAddress`); if (value.proxyKind === "none" && implementationAddress) { throw new Error(`${label} non-proxy contract cannot declare implementationAddress`); } if (value.proxyKind !== "none" && !implementationAddress) { throw new Error(`${label} proxy contract requires implementationAddress`); } return { instanceId, contractInfoKey: value.contractInfoKey, address: address(value.address, `${label}.address`), actualContract, proxyKind: value.proxyKind, ...(implementationAddress ? { implementationAddress } : {}), }; }; export const validateMigrationStatePlan = (value: unknown): MigrationStatePlan => { if (!isRecord(value)) throw new Error("Migration state plan must be an object"); exactKeys(value, ["version", "migrationId", "sourceProject", "networks"], [], "Migration state plan"); if (value.version !== 1) throw new Error("Migration state plan version must be 1"); const migrationId = safeId(value.migrationId, "Migration state plan migrationId"); if (typeof value.sourceProject !== "string" || !value.sourceProject.trim()) { throw new Error("Migration state plan sourceProject is required"); } if (!isRecord(value.networks) || Object.keys(value.networks).length === 0) { throw new Error("Migration state plan must contain at least one network"); } const networks: MigrationStatePlan["networks"] = {}; for (const [network, rawNetwork] of Object.entries(value.networks)) { safeId(network, "Migration target network"); if (!isRecord(rawNetwork)) throw new Error(`Migration network ${network} must be an object`); exactKeys(rawNetwork, [ "sourceNetwork", "releaseStage", "chainId", "manifestDir", "contracts", ], [], `Migration network ${network}`); const sourceNetwork = safeId( rawNetwork.sourceNetwork, `Migration network ${network}.sourceNetwork`, ); if ( rawNetwork.releaseStage !== "development" && rawNetwork.releaseStage !== "production" ) { throw new Error(`Migration network ${network}.releaseStage is invalid`); } if (!Number.isSafeInteger(rawNetwork.chainId) || Number(rawNetwork.chainId) <= 0) { throw new Error(`Migration network ${network}.chainId must be a positive integer`); } if ( typeof rawNetwork.manifestDir !== "string" || !SAFE_MANIFEST_DIR.test(rawNetwork.manifestDir) ) { throw new Error(`Migration network ${network}.manifestDir is unsafe`); } if (!Array.isArray(rawNetwork.contracts) || rawNetwork.contracts.length === 0) { throw new Error(`Migration network ${network} must contain contracts`); } const contracts = rawNetwork.contracts.map((contract, index) => ( validateContractPlan(contract, `Migration network ${network}.contracts[${index}]`) )); if (new Set(contracts.map((contract) => contract.instanceId)).size !== contracts.length) { throw new Error(`Migration network ${network} contains duplicate instanceId values`); } networks[network] = { sourceNetwork, releaseStage: rawNetwork.releaseStage, chainId: Number(rawNetwork.chainId), manifestDir: rawNetwork.manifestDir, contracts, }; } return { version: 1, migrationId, sourceProject: value.sourceProject.trim(), networks, }; }; export const migrationConfirmationToken = ( inputPlan: unknown, network: string, ): string => { const plan = validateMigrationStatePlan(inputPlan); const networkPlan = plan.networks[network]; if (!networkPlan) throw new Error(`Unknown migration network: ${network}`); return [ "migration-v1", plan.migrationId, network, networkPlan.releaseStage, networkPlan.manifestDir, hashValue({ migrationId: plan.migrationId, sourceProject: plan.sourceProject, network, networkPlan, }), ].join(":"); }; export const validateMigrationImportEvidence = ( value: unknown, ): MigrationImportEvidence => { if (!isRecord(value)) throw new Error("Migration import evidence must be an object"); exactKeys(value, [ "version", "status", "integrityHash", "migrationId", "sourceProject", "planHash", "contractInfoHash", "deploymentInfoHash", "networks", "confirmations", "bytecodeCheck", "before", "importedAt", ], [], "Migration import evidence"); if (value.version !== 1 || value.status !== "imported") { throw new Error("Migration import evidence version or status is invalid"); } const migrationId = safeId(value.migrationId, "Migration import evidence migrationId"); const sourceProject = nonEmptyString( value.sourceProject, "Migration import evidence sourceProject", ); if (!isRecord(value.networks) || Object.keys(value.networks).length === 0) { throw new Error("Migration import evidence networks are required"); } const networks: MigrationImportEvidence["networks"] = {}; for (const [network, rawNetwork] of Object.entries(value.networks)) { safeId(network, "Migration import evidence network"); if (!isRecord(rawNetwork)) { throw new Error(`Migration import evidence network ${network} must be an object`); } exactKeys(rawNetwork, [ "sourceNetwork", "releaseStage", "chainId", "manifestDir", "contracts", ], [], `Migration import evidence network ${network}`); if ( rawNetwork.releaseStage !== "development" && rawNetwork.releaseStage !== "production" ) { throw new Error(`Migration import evidence network ${network} releaseStage is invalid`); } if (!Number.isSafeInteger(rawNetwork.chainId) || Number(rawNetwork.chainId) <= 0) { throw new Error(`Migration import evidence network ${network} chainId is invalid`); } if ( typeof rawNetwork.manifestDir !== "string" || !SAFE_MANIFEST_DIR.test(rawNetwork.manifestDir) ) { throw new Error(`Migration import evidence network ${network} manifestDir is unsafe`); } if (!Number.isSafeInteger(rawNetwork.contracts) || Number(rawNetwork.contracts) <= 0) { throw new Error(`Migration import evidence network ${network} contracts is invalid`); } networks[network] = { sourceNetwork: safeId( rawNetwork.sourceNetwork, `Migration import evidence network ${network} sourceNetwork`, ), releaseStage: rawNetwork.releaseStage, chainId: Number(rawNetwork.chainId), manifestDir: rawNetwork.manifestDir, contracts: Number(rawNetwork.contracts), }; } if ( !Array.isArray(value.confirmations) || value.confirmations.length !== Object.keys(networks).length || value.confirmations.some((token) => typeof token !== "string" || !token) || new Set(value.confirmations).size !== value.confirmations.length ) { throw new Error("Migration import evidence confirmations are invalid"); } if (!isRecord(value.bytecodeCheck)) { throw new Error("Migration import evidence bytecodeCheck must be an object"); } exactKeys( value.bytecodeCheck, ["status", "reportPath", "reportHash"], [], "Migration import evidence bytecodeCheck", ); const expectedReportPath = `scripts/migrations/${migrationId}/bytecode-check.json`; if ( value.bytecodeCheck.status !== "passed" || value.bytecodeCheck.reportPath !== expectedReportPath ) { throw new Error("Migration import evidence bytecode report path or status is invalid"); } if (!isRecord(value.before)) { throw new Error("Migration import evidence before must be an object"); } exactKeys( value.before, ["contractInfoHash", "deploymentInfoHash"], [], "Migration import evidence before", ); const canonical: MigrationImportEvidencePayload = { version: 1, status: "imported", migrationId, sourceProject, planHash: sha256(value.planHash, "Migration import evidence planHash"), contractInfoHash: sha256( value.contractInfoHash, "Migration import evidence contractInfoHash", ), deploymentInfoHash: sha256( value.deploymentInfoHash, "Migration import evidence deploymentInfoHash", ), networks, confirmations: [...value.confirmations] as string[], bytecodeCheck: { status: "passed", reportPath: expectedReportPath, reportHash: sha256( value.bytecodeCheck.reportHash, "Migration import evidence bytecode reportHash", ), }, before: { contractInfoHash: sha256( value.before.contractInfoHash, "Migration import evidence before.contractInfoHash", ), deploymentInfoHash: sha256( value.before.deploymentInfoHash, "Migration import evidence before.deploymentInfoHash", ), }, importedAt: isoTimestamp(value.importedAt, "Migration import evidence importedAt"), }; const integrityHash = sha256( value.integrityHash, "Migration import evidence integrityHash", ); if (hashValue(canonical) !== integrityHash) { throw new Error("Migration import evidence integrity hash mismatch"); } return { ...canonical, integrityHash }; }; export const resolveConfinedMigrationPath = ( root: string, requested: string, options: { label?: string; requireFile?: boolean } = {}, ): string => { const label = options.label ?? "Migration path"; const projectRoot = path.resolve(root); const rootStat = lstatIfPresent(projectRoot); if (!rootStat) { throw new Error(`${label} project root does not exist: ${projectRoot}`); } if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { throw new Error(`${label} project root must be a real directory: ${projectRoot}`); } const realRoot = fs.realpathSync(projectRoot); const candidate = path.isAbsolute(requested) ? path.resolve(requested) : path.resolve(projectRoot, requested); const relative = path.relative(projectRoot, candidate); if ( !relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) ) { throw new Error(`${label} escapes the project root: ${requested}`); } const segments = relative.split(path.sep); let current = projectRoot; let missing = false; for (let index = 0; index < segments.length; index += 1) { current = path.join(current, segments[index]); const stat = lstatIfPresent(current); if (!stat) { missing = true; break; } if (stat.isSymbolicLink()) { throw new Error(`${label} cannot traverse a symlink: ${current}`); } if (index < segments.length - 1 && !stat.isDirectory()) { throw new Error(`${label} ancestor is not a directory: ${current}`); } const realCurrent = fs.realpathSync(current); const realRelative = path.relative(realRoot, realCurrent); if ( realRelative === ".." || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative) ) { throw new Error(`${label} resolves outside the project root: ${requested}`); } } if (options.requireFile) { const stat = missing ? undefined : lstatIfPresent(candidate); if (!stat) { throw new Error(`${label} does not exist: ${requested}`); } if (stat.isSymbolicLink() || !stat.isFile()) { throw new Error(`${label} must be a regular file: ${requested}`); } } return candidate; }; const snapshot = (filePath: string): Buffer | undefined => { const stat = lstatIfPresent(filePath); if (!stat) return undefined; if (stat.isSymbolicLink() || !stat.isFile()) { throw new Error(`Migration state path must be a regular file: ${filePath}`); } return fs.readFileSync(filePath); }; const readRegularJson = (filePath: string, label: string): unknown => { const contents = snapshot(filePath); if (contents === undefined) throw new Error(`${label} is missing: ${filePath}`); try { return JSON.parse(contents.toString("utf8")); } catch (error) { throw new Error( `${label} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, ); } }; export const resolveGateToolCandidateCheck = ( result: { status: number | null; error?: { message?: unknown } }, reportPath: string, ): { status: "passed" | "failed"; report: unknown } => { const reportStat = lstatIfPresent(reportPath); if (reportStat && (reportStat.isSymbolicLink() || !reportStat.isFile())) { throw new Error(`gate-tool bytecode report must be a regular file: ${reportPath}`); } if (result.status === 0) { if (!reportStat) { throw new Error(`gate-tool exited zero but did not create a report: ${reportPath}`); } return { status: "passed", report: readRegularJson(reportPath, "gate-tool bytecode report"), }; } const report = !reportStat ? { error: String(result.error?.message ?? `gate-tool exited with ${result.status ?? 1}`) } : readRegularJson(reportPath, "gate-tool bytecode report"); return { status: "failed", report }; }; const serializeJson = (value: unknown): Buffer => ( Buffer.from(`${JSON.stringify(value, null, 2)}\n`) ); const fsyncDirectory = (directory: string): void => { let descriptor: number | undefined; try { descriptor = fs.openSync(directory, "r"); fs.fsyncSync(descriptor); } catch (error) { const code = error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : undefined; if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EPERM") throw error; } finally { if (descriptor !== undefined) fs.closeSync(descriptor); } }; const writeBufferNew = (filePath: string, contents: Buffer): void => { fs.mkdirSync(path.dirname(filePath), { recursive: true }); let descriptor: number | undefined; let created = false; try { descriptor = fs.openSync(filePath, "wx", 0o600); created = true; fs.writeFileSync(descriptor, contents); fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined; fsyncDirectory(path.dirname(filePath)); } catch (error) { if (descriptor !== undefined) fs.closeSync(descriptor); if (created && lstatIfPresent(filePath)) fs.unlinkSync(filePath); throw error; } }; const renameDurable = (sourcePath: string, targetPath: string): void => { fs.renameSync(sourcePath, targetPath); fsyncDirectory(path.dirname(targetPath)); }; const unlinkDurable = (filePath: string): void => { const stat = lstatIfPresent(filePath); if (!stat) return; if (stat.isSymbolicLink() || !stat.isFile()) { throw new Error(`Migration transaction path must be a regular file: ${filePath}`); } fs.unlinkSync(filePath); fsyncDirectory(path.dirname(filePath)); }; const writeBufferAtomic = (filePath: string, contents: Buffer): void => { fs.mkdirSync(path.dirname(filePath), { recursive: true }); const temporary = `${filePath}.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString("hex")}.tmp`; let descriptor: number | undefined; try { descriptor = fs.openSync(temporary, "wx", 0o600); fs.writeFileSync(descriptor, contents); fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined; renameDurable(temporary, filePath); } catch (error) { if (descriptor !== undefined) fs.closeSync(descriptor); if (fs.existsSync(temporary)) fs.unlinkSync(temporary); throw error; } }; const defaultIo: Required> = { writeJson: (filePath, value, options = {}) => { if (options.exclusive) { writeBufferNew(filePath, serializeJson(value)); return; } writeBufferAtomic(filePath, serializeJson(value)); }, rename: renameDurable, }; const restoreSnapshots = ( snapshots: Array<{ filePath: string; contents: Buffer | undefined }>, ): void => { for (const { filePath, contents } of [...snapshots].reverse()) { if (contents === undefined) { unlinkDurable(filePath); } else { writeBufferAtomic(filePath, contents); } } }; interface MigrationCommitTarget { filePath: string; relativePath: string; value: unknown; contents?: Buffer; stagedPath?: string; stagedRelativePath?: string; before: Buffer | undefined; } interface MigrationJournalTarget { relativePath: string; stagedRelativePath: string; beforeBase64: string | null; beforeHash: string; afterHash: string; } interface MigrationTransactionJournal { version: 1; status: "prepared"; migrationId: string; planHash: string; evidenceHash: string; targets: MigrationJournalTarget[]; } const projectRelative = (root: string, filePath: string): string => ( path.relative(root, filePath).split(path.sep).join("/") ); const decodeJournalSnapshot = ( encoded: unknown, expectedHash: string, label: string, ): Buffer | undefined => { if (encoded === null) { if (hashBuffer(undefined) !== expectedHash) { throw new Error(`${label} missing snapshot hash is invalid`); } return undefined; } if (typeof encoded !== "string") { throw new Error(`${label} beforeBase64 must be a string or null`); } const contents = Buffer.from(encoded, "base64"); if (contents.toString("base64") !== encoded || hashBuffer(contents) !== expectedHash) { throw new Error(`${label} before snapshot is invalid`); } return contents; }; const validateMigrationTransactionJournal = ( value: unknown, options: { root: string; plan: MigrationStatePlan; planHash: string; expectedTargets: Array<{ filePath: string; relativePath: string }>; }, ): Array<{ filePath: string; stagedPath: string; before: Buffer | undefined; afterHash: string; }> => { if (!isRecord(value)) throw new Error("Migration transaction journal must be an object"); exactKeys(value, [ "version", "status", "migrationId", "planHash", "evidenceHash", "targets", ], [], "Migration transaction journal"); if ( value.version !== 1 || value.status !== "prepared" || value.migrationId !== options.plan.migrationId || value.planHash !== options.planHash ) { throw new Error("Migration transaction journal does not match the active plan"); } const evidenceHash = sha256( value.evidenceHash, "Migration transaction journal evidenceHash", ); if ( !Array.isArray(value.targets) || value.targets.length !== options.expectedTargets.length ) { throw new Error("Migration transaction journal target set is invalid"); } const stagedPaths = new Set(); const targets = value.targets.map((rawTarget, index) => { const expected = options.expectedTargets[index]; if (!isRecord(rawTarget)) { throw new Error(`Migration transaction journal target ${index} must be an object`); } exactKeys(rawTarget, [ "relativePath", "stagedRelativePath", "beforeBase64", "beforeHash", "afterHash", ], [], `Migration transaction journal target ${index}`); if (rawTarget.relativePath !== expected.relativePath) { throw new Error(`Migration transaction journal target ${index} path is invalid`); } if (typeof rawTarget.stagedRelativePath !== "string") { throw new Error(`Migration transaction journal target ${index} staged path is invalid`); } const stagedRelativePath = rawTarget.stagedRelativePath; const expectedDirectory = path.posix.dirname(expected.relativePath); const stagedName = path.posix.basename(stagedRelativePath); if ( path.posix.dirname(stagedRelativePath) !== expectedDirectory || !stagedName.startsWith(`.${path.posix.basename(expected.relativePath)}.`) || !stagedName.endsWith(".stage") || stagedPaths.has(stagedRelativePath) ) { throw new Error(`Migration transaction journal target ${index} staged path is unsafe`); } stagedPaths.add(stagedRelativePath); const beforeHash = sha256( rawTarget.beforeHash, `Migration transaction journal target ${index} beforeHash`, ); const afterHash = sha256( rawTarget.afterHash, `Migration transaction journal target ${index} afterHash`, ); const stagedPath = resolveConfinedMigrationPath(options.root, stagedRelativePath, { label: `Migration transaction staged target ${index}`, }); return { filePath: expected.filePath, stagedPath, before: decodeJournalSnapshot( rawTarget.beforeBase64, beforeHash, `Migration transaction journal target ${index}`, ), afterHash, }; }); if (targets.at(-1)?.afterHash !== evidenceHash) { throw new Error("Migration transaction journal evidence hash is inconsistent"); } return targets; }; const recoverMigrationTransaction = (options: { root: string; journalFile: string; plan: MigrationStatePlan; planHash: string; expectedTargets: Array<{ filePath: string; relativePath: string }>; }): "none" | "rolled_back" | "committed" => { if (!fs.existsSync(options.journalFile)) return "none"; const targets = validateMigrationTransactionJournal( readRegularJson(options.journalFile, "Migration transaction journal"), options, ); const committed = targets.every((target) => { const contents = snapshot(target.filePath); return contents !== undefined && hashBuffer(contents) === target.afterHash; }); if (!committed) { restoreSnapshots(targets.map((target) => ({ filePath: target.filePath, contents: target.before, }))); } for (const target of targets) unlinkDurable(target.stagedPath); unlinkDurable(options.journalFile); return committed ? "committed" : "rolled_back"; }; const stageMigrationTargets = ( root: string, targets: MigrationCommitTarget[], io: Required> & MigrationStateIo, ): void => { const nonce = `${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString("hex")}`; for (const target of targets) { target.stagedRelativePath = path.posix.join( path.posix.dirname(target.relativePath), `.${path.posix.basename(target.relativePath)}.${nonce}.stage`, ); target.stagedPath = resolveConfinedMigrationPath(root, target.stagedRelativePath, { label: "Migration staged output", }); io.writeJson(target.stagedPath, target.value, { exclusive: true }); target.contents = snapshot(target.stagedPath); if (target.contents === undefined) { throw new Error(`Migration staged output is missing: ${target.stagedRelativePath}`); } let stagedValue: unknown; try { stagedValue = JSON.parse(target.contents.toString("utf8")); } catch (error) { throw new Error( `Migration staged output is invalid JSON: ${error instanceof Error ? error.message : String(error)}`, ); } if (hashValue(stagedValue) !== hashValue(target.value)) { throw new Error(`Migration staged output changed value: ${target.stagedRelativePath}`); } } }; const cleanupUncommittedStages = (targets: MigrationCommitTarget[]): void => { for (const target of targets) { if (target.stagedPath) unlinkDurable(target.stagedPath); } }; export const importMigrationState = async ( options: ImportMigrationStateOptions, ): Promise => { const root = path.resolve(options.root); fs.mkdirSync(root, { recursive: true }); const plan = validateMigrationStatePlan(options.plan); const expectedConfirmations = Object.keys(plan.networks).sort().map((network) => ( migrationConfirmationToken(plan, network) )); const providedConfirmations = new Set(options.confirmations); for (const [index, token] of expectedConfirmations.entries()) { if (!providedConfirmations.has(token)) { const network = Object.keys(plan.networks).sort()[index]; throw new Error(`Missing migration confirmation for ${network}: ${token}`); } } const evidenceRelative = `scripts/migrations/${plan.migrationId}/state-import.json`; const bytecodeReportRelative = `scripts/migrations/${plan.migrationId}/bytecode-check.json`; const journalRelative = `scripts/migrations/${plan.migrationId}/.state-import.transaction.json`; const evidenceFile = resolveConfinedMigrationPath(root, evidenceRelative, { label: "Migration evidence path", }); const bytecodeReportFile = resolveConfinedMigrationPath(root, bytecodeReportRelative, { label: "Migration bytecode report path", }); const contractInfoFile = resolveConfinedMigrationPath(root, "contractInfo.json", { label: "Migration contractInfo path", }); const deploymentInfoFile = resolveConfinedMigrationPath(root, "deploymentInfo.json", { label: "Migration deploymentInfo path", }); const journalFile = resolveConfinedMigrationPath(root, journalRelative, { label: "Migration transaction journal path", }); const planHash = hashValue(plan); let previousEvidence: MigrationImportEvidence | undefined; if (fs.existsSync(evidenceFile)) { previousEvidence = validateMigrationImportEvidence( readRegularJson(evidenceFile, "Migration import evidence"), ); } const contractInfo: ContractInfo = {}; const deploymentInfo: DeploymentInfo = { version: 1, networks: {} }; const importedAt = previousEvidence?.version === 1 && previousEvidence.status === "imported" && previousEvidence.migrationId === plan.migrationId && previousEvidence.planHash === planHash ? previousEvidence.importedAt : (options.now ?? (() => new Date().toISOString()))(); for (const [network, networkPlan] of Object.entries(plan.networks)) { const target = options.targets[network]; if (!target) throw new Error(`Missing target config for migration network ${network}`); if (target.environment !== networkPlan.releaseStage) { throw new Error( `Migration release stage mismatch for ${network}: expected ${networkPlan.releaseStage}, received ${target.environment}`, ); } if (target.openzeppelin?.manifestDir !== networkPlan.manifestDir) { throw new Error( `Migration manifest directory mismatch for ${network}: expected ${networkPlan.manifestDir}, received ${String(target.openzeppelin?.manifestDir)}`, ); } contractInfo[network] = {}; deploymentInfo.networks[network] = {}; for (const contract of networkPlan.contracts) { const inspection = await options.inspect(network, contract); if (inspection.chainId !== networkPlan.chainId) { throw new Error( `ChainId mismatch for ${network}.${contract.instanceId}: expected ${networkPlan.chainId}, received ${inspection.chainId}`, ); } if (!inspection.hasCode) { throw new Error(`No chain code for ${network}.${contract.instanceId}`); } if (!inspection.artifactExists) { throw new Error(`Artifact does not exist for ${network}.${contract.actualContract}`); } if ( inspection.address && address(inspection.address, `${network}.${contract.instanceId} inspected address`).toLowerCase() !== contract.address.toLowerCase() ) { throw new Error(`Address mismatch for ${network}.${contract.instanceId}`); } if (inspection.proxyKind !== contract.proxyKind) { throw new Error( `Proxy kind mismatch for ${network}.${contract.instanceId}: expected ${contract.proxyKind}, received ${inspection.proxyKind}`, ); } const inspectedImplementation = inspection.implementationAddress ? address( inspection.implementationAddress, `${network}.${contract.instanceId} inspected implementation`, ) : undefined; if ( (contract.implementationAddress ?? "").toLowerCase() !== (inspectedImplementation ?? "").toLowerCase() ) { throw new Error(`Implementation address mismatch for ${network}.${contract.instanceId}`); } contractInfo[network][contract.contractInfoKey] = contract.address; deploymentInfo.networks[network][contract.instanceId] = { contractInfoKey: contract.contractInfoKey, address: contract.address, actualContract: contract.actualContract, ...(contract.proxyKind === "none" ? {} : { proxyKind: contract.proxyKind }), ...(contract.implementationAddress ? { implementationAddress: contract.implementationAddress } : {}), source: { kind: "migration", migrationId: plan.migrationId, evidencePath: evidenceRelative, }, updatedAt: importedAt, }; } } const canonicalContractInfo = validateContractInfo(contractInfo); const canonicalDeploymentInfo = validateDeploymentInfo(deploymentInfo); const bytecodeCheck = await options.verifyCandidate(canonicalContractInfo); if (bytecodeCheck.status !== "passed") { throw new Error("Migration candidate bytecode verification failed"); } const contractInfoHash = hashValue(canonicalContractInfo); const deploymentInfoHash = hashValue(canonicalDeploymentInfo); const expectedCommitTargets = [ { filePath: contractInfoFile, relativePath: "contractInfo.json" }, { filePath: deploymentInfoFile, relativePath: "deploymentInfo.json" }, { filePath: bytecodeReportFile, relativePath: bytecodeReportRelative }, { filePath: evidenceFile, relativePath: evidenceRelative }, ]; return withContractInfoLock(root, () => { recoverMigrationTransaction({ root, journalFile, plan, planHash, expectedTargets: expectedCommitTargets, }); if (fs.existsSync(evidenceFile)) { const existing = validateMigrationImportEvidence( readRegularJson(evidenceFile, "Migration import evidence"), ); const existingContractInfo = readRegularJson( contractInfoFile, "Migration contractInfo", ); const existingDeploymentInfo = readRegularJson( deploymentInfoFile, "Migration deploymentInfo", ); const existingBytecodeReport = readRegularJson( bytecodeReportFile, "Migration bytecode report", ); const expectedNetworks = Object.fromEntries( Object.entries(plan.networks).map(([network, networkPlan]) => [ network, { sourceNetwork: networkPlan.sourceNetwork, releaseStage: networkPlan.releaseStage, chainId: networkPlan.chainId, manifestDir: networkPlan.manifestDir, contracts: networkPlan.contracts.length, }, ]), ); if ( existing.version === 1 && existing.status === "imported" && existing.migrationId === plan.migrationId && existing.sourceProject === plan.sourceProject && existing.planHash === planHash && existing.contractInfoHash === contractInfoHash && existing.deploymentInfoHash === deploymentInfoHash && hashValue(existing.networks) === hashValue(expectedNetworks) && hashValue(existing.confirmations) === hashValue(expectedConfirmations) && existing.bytecodeCheck.reportPath === bytecodeReportRelative && hashValue(existingBytecodeReport) === existing.bytecodeCheck.reportHash && hashValue(existingContractInfo) === contractInfoHash && hashValue(existingDeploymentInfo) === deploymentInfoHash ) { return existing; } if (hashValue(existingBytecodeReport) !== existing.bytecodeCheck.reportHash) { throw new Error(`Migration bytecode report hash mismatch: ${bytecodeReportRelative}`); } throw new Error(`Migration evidence already exists with different state: ${evidenceRelative}`); } if (fs.existsSync(bytecodeReportFile)) { throw new Error( `Migration bytecode report already exists without committed evidence: ${bytecodeReportRelative}`, ); } const snapshots = [ { filePath: contractInfoFile, contents: snapshot(contractInfoFile) }, { filePath: deploymentInfoFile, contents: snapshot(deploymentInfoFile) }, { filePath: bytecodeReportFile, contents: snapshot(bytecodeReportFile) }, { filePath: evidenceFile, contents: snapshot(evidenceFile) }, ]; const evidencePayload: MigrationImportEvidencePayload = { version: 1, status: "imported", migrationId: plan.migrationId, sourceProject: plan.sourceProject, planHash, contractInfoHash, deploymentInfoHash, networks: Object.fromEntries(Object.entries(plan.networks).map(([network, networkPlan]) => [ network, { sourceNetwork: networkPlan.sourceNetwork, releaseStage: networkPlan.releaseStage, chainId: networkPlan.chainId, manifestDir: networkPlan.manifestDir, contracts: networkPlan.contracts.length, }, ])), confirmations: expectedConfirmations, bytecodeCheck: { status: "passed", reportPath: bytecodeReportRelative, reportHash: hashValue(bytecodeCheck.report), }, before: { contractInfoHash: hashBuffer(snapshots[0].contents), deploymentInfoHash: hashBuffer(snapshots[1].contents), }, importedAt, }; const evidence: MigrationImportEvidence = { ...evidencePayload, integrityHash: hashValue(evidencePayload), }; const io = { ...defaultIo, ...(options.io ?? {}), } as Required> & MigrationStateIo; const commitTargets: MigrationCommitTarget[] = [ { ...expectedCommitTargets[0], value: canonicalContractInfo, before: snapshots[0].contents, }, { ...expectedCommitTargets[1], value: canonicalDeploymentInfo, before: snapshots[1].contents, }, { ...expectedCommitTargets[2], value: bytecodeCheck.report, before: snapshots[2].contents, }, { ...expectedCommitTargets[3], value: evidence, before: snapshots[3].contents, }, ]; try { stageMigrationTargets(root, commitTargets, io); const journalTargets: MigrationJournalTarget[] = commitTargets.map((target) => ({ relativePath: target.relativePath, stagedRelativePath: target.stagedRelativePath as string, beforeBase64: target.before === undefined ? null : target.before.toString("base64"), beforeHash: hashBuffer(target.before), afterHash: hashBuffer(target.contents), })); const journal: MigrationTransactionJournal = { version: 1, status: "prepared", migrationId: plan.migrationId, planHash, evidenceHash: journalTargets.at(-1)?.afterHash as string, targets: journalTargets, }; writeBufferNew(journalFile, serializeJson(journal)); for (const [index, target] of commitTargets.entries()) { io.rename(target.stagedPath as string, target.filePath); io.afterRename?.(target.stagedPath as string, target.filePath, index); const committedContents = snapshot(target.filePath); if ( committedContents === undefined || hashBuffer(committedContents) !== journalTargets[index].afterHash ) { throw new Error(`Migration commit verification failed: ${target.relativePath}`); } } // Evidence is renamed last and is the immutable transaction commit marker. unlinkDurable(journalFile); return evidence; } catch (error) { if (fs.existsSync(journalFile)) { const disposition = recoverMigrationTransaction({ root, journalFile, plan, planHash, expectedTargets: expectedCommitTargets, }); if (disposition === "committed") return evidence; } else { cleanupUncommittedStages(commitTargets); restoreSnapshots(snapshots); } throw error; } }); };