import fs from "fs"; import path from "path"; import { isAddress } from "ethers"; import type { WorkflowConfig } from "./config"; import { readJsonFile, writeJsonFile } from "./config"; export type ContractInfo = Record>; export interface ResolvedDeployment { key: string; address: string; } const contractInfoPath = (root: string): string => { return path.join(root, "contractInfo.json"); }; const sleepSync = (milliseconds: number): void => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); }; const errorCode = (error: unknown): string | undefined => { return error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : undefined; }; const lockOwnerIsAlive = (lockPath: string): boolean | undefined => { let ownerPid: number; try { ownerPid = Number.parseInt(fs.readFileSync(lockPath, "utf8").split("-", 1)[0], 10); } catch (error) { if (errorCode(error) === "ENOENT") return false; return undefined; } if (!Number.isSafeInteger(ownerPid) || ownerPid <= 0) return undefined; try { process.kill(ownerPid, 0); return true; } catch (error) { if (errorCode(error) === "ESRCH") return false; return true; } }; export const withContractInfoLock = (root: string, operation: () => T): T => { const filePath = contractInfoPath(root); const lockPath = `${filePath}.lock`; const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; const startedAt = Date.now(); const timeoutMs = 10_000; const staleMs = 30_000; let lockFile: number | undefined; fs.mkdirSync(path.dirname(lockPath), { recursive: true }); while (lockFile === undefined) { try { const candidate = fs.openSync(lockPath, "wx"); try { fs.writeFileSync(candidate, `${token}\n`); lockFile = candidate; } catch (error) { fs.closeSync(candidate); fs.unlinkSync(lockPath); throw error; } } catch (error) { if (errorCode(error) !== "EEXIST") throw error; try { const ownerAlive = lockOwnerIsAlive(lockPath); const lockAgeMs = Date.now() - fs.statSync(lockPath).mtimeMs; if ( ownerAlive === false || (ownerAlive === undefined && lockAgeMs > staleMs) ) { fs.unlinkSync(lockPath); continue; } } catch (statError) { if (errorCode(statError) === "ENOENT") continue; throw statError; } if (Date.now() - startedAt >= timeoutMs) { throw new Error(`Timed out waiting for contractInfo lock: ${lockPath}`); } sleepSync(25); } } let operationFailed = false; try { return operation(); } catch (error) { operationFailed = true; throw error; } finally { try { fs.closeSync(lockFile); if (fs.readFileSync(lockPath, "utf8").trim() === token) { fs.unlinkSync(lockPath); } } catch (error) { if (!operationFailed && errorCode(error) !== "ENOENT") throw error; } } }; const isRecord = (value: unknown): value is Record => { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }; export const validateContractInfo = (value: unknown): ContractInfo => { if (!isRecord(value)) { throw new Error("contractInfo.json must contain an object keyed by network"); } const contractInfo: ContractInfo = {}; for (const [targetId, targetValue] of Object.entries(value)) { if (!isRecord(targetValue)) { throw new Error(`contractInfo.json network ${targetId} must contain an object`); } const entries: Record = {}; const instanceIds = new Set(); for (const [name, address] of Object.entries(targetValue)) { const keyParts = name.split(":"); if ( keyParts.length > 2 || keyParts.some((part) => !part.trim() || part !== part.trim()) ) { throw new Error( `contractInfo.json key ${targetId}.${name} must use Contract or saveAs:Contract format`, ); } const instanceId = keyParts[0]; if (instanceIds.has(instanceId)) { throw new Error( `contractInfo.json network ${targetId} defines deployment instance ${instanceId} more than once`, ); } instanceIds.add(instanceId); if (typeof address !== "string" || !isAddress(address)) { throw new Error( `contractInfo.json entry ${targetId}.${name} must be an address string with a valid EVM address`, ); } entries[name] = address; } contractInfo[targetId] = entries; } return contractInfo; }; export const cloneContractInfo = (contractInfo: ContractInfo): ContractInfo => { return Object.fromEntries( Object.entries(contractInfo).map(([targetId, entries]) => [ targetId, { ...entries }, ]), ); }; export const actualContractName = (contractName: string): string => { const trimmed = contractName.trim(); if (!trimmed) { throw new Error("Actual contract name must not be empty"); } const separator = trimmed.lastIndexOf(":"); const actual = separator >= 0 ? trimmed.slice(separator + 1).trim() : trimmed; if (!actual) { throw new Error(`Invalid fully qualified contract name: ${contractName}`); } return actual; }; export const deploymentInstanceId = (instanceId: string): string => { const normalizedInstanceId = instanceId.trim(); if (!normalizedInstanceId) { throw new Error("Deployment saveAs must not be empty"); } if (normalizedInstanceId.includes(":")) { throw new Error(`Deployment saveAs "${normalizedInstanceId}" must not contain ":"`); } return normalizedInstanceId; }; export const contractInfoKey = (instanceId: string, contractName: string): string => { const normalizedInstanceId = deploymentInstanceId(instanceId); const actual = actualContractName(contractName); return normalizedInstanceId === actual ? actual : `${normalizedInstanceId}:${actual}`; }; export const loadContractInfo = (root: string, _config: WorkflowConfig): ContractInfo => { const filePath = contractInfoPath(root); if (!fs.existsSync(filePath)) return {}; return validateContractInfo(readJsonFile(filePath)); }; export const saveContractInfo = (root: string, contractInfo: ContractInfo): void => { writeJsonFile(contractInfoPath(root), validateContractInfo(contractInfo)); }; export const planDeploymentAddress = ( contractInfo: ContractInfo, targetId: string, instanceId: string, contractName: string, address: string, ): { key: string; contractInfo: ContractInfo } => { const key = contractInfoKey(instanceId, contractName); const normalizedInstanceId = deploymentInstanceId(instanceId); const planned = cloneContractInfo(contractInfo); const targetEntries = { ...(planned[targetId] || {}) }; for (const existingKey of Object.keys(targetEntries)) { if ( existingKey === normalizedInstanceId || existingKey.startsWith(`${normalizedInstanceId}:`) ) { delete targetEntries[existingKey]; } } targetEntries[key] = address; planned[targetId] = targetEntries; return { key, contractInfo: planned }; }; export const resolveDeployment = ( contractInfo: ContractInfo, targetId: string, name: string, ): ResolvedDeployment | undefined => { const entries = contractInfo[targetId] || {}; const exactAddress = entries[name]; if (exactAddress) { return { key: name, address: exactAddress }; } const aliases = Object.entries(entries).filter(([key]) => key.startsWith(`${name}:`)); if (aliases.length > 1) { throw new Error( `Ambiguous contractInfo deployment ${targetId}.${name}; matches ${aliases.map(([key]) => key).join(", ")}`, ); } if (aliases.length === 1) { return { key: aliases[0][0], address: aliases[0][1] }; } return undefined; }; export const findDeploymentAddress = ( root: string, config: WorkflowConfig, targetId: string, name: string, ): string | undefined => { return resolveDeployment(loadContractInfo(root, config), targetId, name)?.address; }; export const getDeploymentAddress = ( root: string, config: WorkflowConfig, targetId: string, name: string, ): string => { const deployment = resolveDeployment(loadContractInfo(root, config), targetId, name); if (!deployment) { throw new Error(`Missing contractInfo address: ${targetId}.${name}`); } return deployment.address; };