import crypto from "crypto"; import fs from "fs"; import path from "path"; import { writeJsonFile } from "./config"; import { validateExecutionId } from "./executionId"; import { hashValue, ReleaseFingerprint } from "./release"; import type { ExistingDeploymentPolicies } from "./parameters"; const sha256 = (value: string): string => { return crypto.createHash("sha256").update(value).digest("hex"); }; export interface ParameterLockTargetSnapshot { network: string; signer: string; admin: string; chainId?: number; create2Factory?: string; create2Caller?: string; controlledMulticall?: string; uupsPlaceholderImplementation?: string; leadingZeroBytes?: number; } export type FinalSimulation = | { status: "pending" } | { status: "succeeded"; result: string; simulatedAt: string; executionPlanHash: string; executionPlanPath: string; }; export interface ParameterLockInput { taskDir: string; deploymentId: string; executionId: string; sourceSuperTaskId: string; mergeCommit: string; selectedExecuteNetworks: string[]; selectedStepIds: string[]; operator: string; baselineParameters: Record; overrides: Record; resolvedParameters: Record; releaseFingerprint: ReleaseFingerprint; finalSimulation: FinalSimulation; targetSnapshot: ParameterLockTargetSnapshot; existingDeploymentPolicies?: ExistingDeploymentPolicies; implementationSalts?: Record; } export interface ParameterLockExecution extends Omit { manifestHash: string; parameterHash: string; lockedAt: string; target: ParameterLockTargetSnapshot; } interface ParameterLockFile { version: 2; deploymentId: string; latestExecutionId: string; manifestHash: string; parametersHash: string; executions: ParameterLockExecution[]; } const isRecord = (value: unknown): value is Record => { return !!value && typeof value === "object" && !Array.isArray(value); }; const isNonZeroEvmAddress = (value: unknown): value is string => { return typeof value === "string" && /^0x[0-9a-fA-F]{40}$/.test(value) && value.toLowerCase() !== "0x0000000000000000000000000000000000000000"; }; const assertSignerBinding = ( execution: Pick, ): void => { if (!isNonZeroEvmAddress(execution.operator)) { throw new Error("Parameter lock operator must be a valid non-zero EVM address"); } if (!isNonZeroEvmAddress(execution.target?.signer)) { throw new Error("Parameter lock target signer must be a valid non-zero EVM address"); } if (execution.operator.toLowerCase() !== execution.target.signer.toLowerCase()) { throw new Error("Parameter lock operator does not match target signer"); } }; const assertFinalSimulation = ( execution: Pick, ): void => { const evidence = execution.finalSimulation; if (!isRecord(evidence) || (evidence.status !== "pending" && evidence.status !== "succeeded")) { throw new Error("Parameter lock finalSimulation is invalid"); } if (evidence.status === "pending") { if (Object.keys(evidence).length !== 1) { throw new Error("Pending finalSimulation cannot contain execution evidence"); } return; } if (typeof evidence.result !== "string" || !evidence.result.trim()) { throw new Error("Succeeded finalSimulation requires result"); } if (typeof evidence.simulatedAt !== "string" || !Number.isFinite(Date.parse(evidence.simulatedAt))) { throw new Error("Succeeded finalSimulation requires simulatedAt"); } if (typeof evidence.executionPlanHash !== "string" || !/^[0-9a-f]{64}$/.test(evidence.executionPlanHash)) { throw new Error("Succeeded finalSimulation requires executionPlanHash"); } const expectedPlanPath = [ "results", execution.target.network, execution.executionId, "execution-plan.json", ].join("/"); if (evidence.executionPlanPath !== expectedPlanPath) { throw new Error("Succeeded finalSimulation executionPlanPath does not match its execution"); } }; const validateLockFile = (value: unknown): ParameterLockFile => { if (!isRecord(value)) { throw new Error("parameters.lock.json must be an object"); } if (!Array.isArray(value.executions)) { throw new Error("parameters.lock.json.executions must be an array"); } const executionIds = new Set(); for (const [index, execution] of value.executions.entries()) { if (!isRecord(execution)) { throw new Error(`parameters.lock.json.executions[${index}] must be an object`); } const executionId = validateExecutionId( execution.executionId, `parameters.lock.json.executions[${index}].executionId`, ); if (executionIds.has(executionId)) { throw new Error("parameters.lock.json contains duplicate executionId: " + executionId); } executionIds.add(executionId); assertSignerBinding(execution as unknown as ParameterLockExecution); assertFinalSimulation(execution as unknown as ParameterLockExecution); } const latestExecutionId = validateExecutionId( value.latestExecutionId, "parameters.lock.json.latestExecutionId", ); const latestMatches = value.executions.filter( (execution) => execution.executionId === latestExecutionId, ).length; if (latestMatches !== 1) { throw new Error( "parameters.lock.json.latestExecutionId must identify exactly one execution", ); } return value as unknown as ParameterLockFile; }; const writeLockFile = (filePath: string, lock: ParameterLockFile): void => { writeJsonFile(filePath, validateLockFile(lock)); }; const readLockFile = (filePath: string): ParameterLockFile | undefined => { if (!fs.existsSync(filePath)) return undefined; let parsed: unknown; try { parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch (error) { throw new Error( "parameters.lock.json must contain valid JSON: " + (error instanceof Error ? error.message : String(error)), ); } return validateLockFile(parsed); }; const immutableExecutionHash = (execution: ParameterLockExecution): string => { const { finalSimulation: _finalSimulation, lockedAt: _lockedAt, ...immutable } = execution; return hashValue(immutable); }; const requireSingleNetwork = (networks: string[]): void => { if (networks.length !== 1 || !networks[0]) { throw new Error("A parameter lock execution must select exactly one network"); } }; const buildExecution = ( input: ParameterLockInput, manifestHash: string, parameterHash: string, ): ParameterLockExecution => { const { taskDir: _taskDir, targetSnapshot, ...executionInput } = input; return { ...executionInput, manifestHash, parameterHash, lockedAt: new Date().toISOString(), target: targetSnapshot, }; }; export const writeParameterLock = (input: ParameterLockInput): void => { const executionId = validateExecutionId(input.executionId); requireSingleNetwork(input.selectedExecuteNetworks); if (input.finalSimulation.status !== "pending") { throw new Error("A new parameter lock execution must start with pending finalSimulation"); } const manifest = fs.readFileSync(path.join(input.taskDir, "manifest.yaml"), "utf8"); const parameters = fs.readFileSync(path.join(input.taskDir, "parameters.yaml"), "utf8"); const manifestHash = sha256(manifest); const parameterHash = sha256(parameters); const lockPath = path.join(input.taskDir, "parameters.lock.json"); const existing = readLockFile(lockPath); const execution = buildExecution(input, manifestHash, parameterHash); const previousExecution = existing?.executions.find((item) => item.executionId === executionId); if (previousExecution) { if (immutableExecutionHash(previousExecution) !== immutableExecutionHash(execution)) { throw new Error("executionId already exists with different content: " + executionId); } return; } writeLockFile(lockPath, { version: 2, deploymentId: input.deploymentId, latestExecutionId: executionId, manifestHash, parametersHash: parameterHash, executions: [...(existing?.executions || []), execution], } satisfies ParameterLockFile); }; export const readParameterLockExecution = ( taskDir: string, executionId: string, ): ParameterLockExecution => { const validatedExecutionId = validateExecutionId(executionId); const lockPath = path.join(taskDir, "parameters.lock.json"); const lock = readLockFile(lockPath); if (!lock) { throw new Error("Missing parameters.lock.json"); } const execution = lock.executions.find((item) => item.executionId === validatedExecutionId); if (!execution) { throw new Error("Unknown parameter lock executionId: " + validatedExecutionId); } return execution; }; export const assertParameterLockExecutable = ( execution: ParameterLockExecution, targetId: string, currentFingerprint: ReleaseFingerprint, ): ParameterLockExecution => { validateExecutionId(execution.executionId); assertSignerBinding(execution); if ( execution.selectedExecuteNetworks.length !== 1 || execution.selectedExecuteNetworks[0] !== targetId || execution.target.network !== targetId ) { throw new Error("Parameter lock target does not match requested network " + targetId); } for (const key of ["manifestHash", "artifactHash", "parametersHash", "configHash"] as const) { if (execution.releaseFingerprint[key] !== currentFingerprint[key]) { throw new Error("parameter lock fingerprint changed: " + key); } } if (execution.finalSimulation.status !== "succeeded") { throw new Error("Parameter lock final simulation has not succeeded"); } return execution; }; export const recordParameterLockImplementationSalts = ( taskDir: string, executionId: string, salts: Record, ): void => { const validatedExecutionId = validateExecutionId(executionId); const lockPath = path.join(taskDir, "parameters.lock.json"); const lock = readLockFile(lockPath); if (!lock) throw new Error("Missing parameters.lock.json"); const index = lock.executions.findIndex((item) => item.executionId === validatedExecutionId); if (index < 0) { throw new Error("Unknown parameter lock executionId: " + validatedExecutionId); } const existing = lock.executions[index].implementationSalts || {}; const next = { ...existing }; for (const [key, salt] of Object.entries(salts)) { if (!key.trim()) throw new Error("implementation salt key must not be empty"); if (!/^0x[0-9a-fA-F]{64}$/.test(salt)) { throw new Error("implementation salt must be bytes32 for " + key); } if (existing[key] && existing[key].toLowerCase() !== salt.toLowerCase()) { throw new Error("implementation salt already locked with different content: " + key); } next[key] = salt; } lock.executions[index] = { ...lock.executions[index], implementationSalts: next, }; writeLockFile(lockPath, lock); }; export const markParameterLockSimulationSucceeded = ( taskDir: string, executionId: string, result: string, simulatedAt: string, signerAddress: string, executionPlanHash: string, executionPlanPath: string, ): void => { const validatedExecutionId = validateExecutionId(executionId); const lockPath = path.join(taskDir, "parameters.lock.json"); const lock = readLockFile(lockPath); if (!lock) { throw new Error("Missing parameters.lock.json"); } const index = lock.executions.findIndex((item) => item.executionId === validatedExecutionId); if (index < 0) { throw new Error("Unknown parameter lock executionId: " + validatedExecutionId); } const execution = lock.executions[index]; assertSignerBinding(execution); if ( !isNonZeroEvmAddress(signerAddress) || execution.operator.toLowerCase() !== signerAddress.toLowerCase() ) { throw new Error("Simulation signer does not match parameter lock operator"); } const current = execution.finalSimulation; if (current.status === "succeeded") { if ( current.result !== result || current.simulatedAt !== simulatedAt || current.executionPlanHash !== executionPlanHash || current.executionPlanPath !== executionPlanPath ) { throw new Error("finalSimulation already succeeded with different evidence"); } return; } lock.executions[index] = { ...lock.executions[index], finalSimulation: { status: "succeeded", result, simulatedAt, executionPlanHash, executionPlanPath, }, }; writeLockFile(lockPath, lock); };