import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { getAddress, ZeroAddress } from "ethers"; import YAML from "yaml"; import { hashValue } from "./release"; export interface SimulationCallerNetworkInput { default: string; steps?: Record; } export interface SimulationCallersDocument { version: 1; networks: Record; } export interface ResolvedSimulationCaller { stepId: string; callerAddress: string; source: "step" | "network_default"; } export interface SimulationCallerNetworkBinding { network: string; manifestHash: string; defaultAddress: string; resolved: ResolvedSimulationCaller[]; networkHash: string; } interface ManifestStepInput { id: string; kind: string; } const UNSAFE_KEYS = new Set(["__proto__", "prototype", "constructor"]); const record = (): Record => ( Object.create(null) as Record ); const stringKey = (value: unknown, label: string): string => { if (typeof value !== "string" || !value || value !== value.trim()) { throw new Error(`${label} must be a non-empty string without surrounding whitespace`); } if (value === "<<" || UNSAFE_KEYS.has(value)) { throw new Error(`${label} contains an unsafe key`); } return value; }; const normalizedAddress = (value: unknown, label: string): string => { if (typeof value !== "string" || !value || value !== value.trim()) { throw new Error(`${label} must be a valid non-zero EVM address`); } try { const address = getAddress(value); if (address === ZeroAddress) throw new Error("zero address"); return address; } catch { throw new Error(`${label} must be a valid non-zero EVM address`); } }; const entriesOf = (value: unknown, label: string): Array<[string, unknown]> => { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} must be a mapping`); } return Object.keys(value as Record).map((key) => [ stringKey(key, `${label} key`), (value as Record)[key], ]); }; const normalizeDocument = (value: unknown): SimulationCallersDocument => { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("simulation callers document must be a mapping"); } const document = value as Record; const keys = Object.keys(document); if (keys.length !== 2 || !keys.includes("version") || !keys.includes("networks")) { throw new Error("simulation callers document contains unsupported or missing fields"); } if (document.version !== 1 && document.version !== "1") { throw new Error("simulation callers version must equal 1"); } const networks = record(); for (const [network, networkValue] of entriesOf( document.networks, "simulation callers networks", )) { const networkEntries = entriesOf(networkValue, `simulation caller network ${network}`); const networkKeys = new Set(networkEntries.map(([key]) => key)); if (!networkKeys.has("default")) { throw new Error(`simulation caller network ${network} is missing required field default`); } if ([...networkKeys].some((key) => key !== "default" && key !== "steps")) { throw new Error(`simulation caller network ${network} contains unsupported fields`); } const networkRecord = Object.fromEntries(networkEntries) as Record; const defaultAddress = normalizedAddress( networkRecord.default, `simulation caller network ${network} default`, ); const stepsValue = networkRecord.steps; let steps: Record | undefined; if (stepsValue !== undefined) { steps = record(); for (const [stepId, address] of entriesOf( stepsValue, `simulation caller network ${network} steps`, )) { steps[stepId] = normalizedAddress( address, `simulation caller network ${network} step ${stepId}`, ); } } networks[network] = { default: defaultAddress, ...(steps ? { steps } : {}), }; } return { version: 1, networks }; }; const parseYamlValue = (text: string): unknown => { if (typeof text !== "string") throw new Error("simulation callers YAML must be text"); const document = YAML.parseDocument(text, { merge: false, prettyErrors: true, schema: "failsafe", uniqueKeys: true, }); if (document.errors.length > 0) { throw new Error(`invalid simulation callers YAML: ${document.errors[0].message}`); } const compatibleDocument = document as unknown as { toJS?: (options: { mapAsMap: boolean; maxAliasCount: number }) => unknown; toJSON: () => unknown; }; return typeof compatibleDocument.toJS === "function" ? compatibleDocument.toJS({ mapAsMap: true, maxAliasCount: 0 }) : compatibleDocument.toJSON(); }; const mapToNullRecords = (value: unknown, label: string): unknown => { if (value instanceof Map) { const output = record(); for (const [keyValue, child] of value.entries()) { const key = stringKey(keyValue, `${label} key`); output[key] = mapToNullRecords(child, `${label}.${key}`); } return output; } if (Array.isArray(value)) { return value.map((child, index) => mapToNullRecords(child, `${label}[${index}]`)); } return value; }; const validatedManifestSteps = (steps: ManifestStepInput[]): ManifestStepInput[] => { if (!Array.isArray(steps) || steps.length === 0) { throw new Error("Manifest must contain at least one simulation caller step"); } const seen = new Set(); return steps.map((step, index) => { if (!step || typeof step !== "object") { throw new Error(`Manifest step ${index} is invalid`); } const id = stringKey(step.id, `Manifest step ${index} id`); if (seen.has(id)) throw new Error(`Duplicate Manifest step ${id}`); seen.add(id); if (typeof step.kind !== "string" || !step.kind || step.kind !== step.kind.trim()) { throw new Error(`Manifest step ${id} kind must be a non-empty string`); } return { id, kind: step.kind }; }); }; const manifestHash = (value: string): string => ( crypto.createHash("sha256").update(value).digest("hex") ); export const parseSimulationCallersYaml = (text: string): SimulationCallersDocument => { return normalizeDocument( mapToNullRecords(parseYamlValue(text), "simulation callers document"), ); }; export const validateSimulationCallersDocument = (input: { document: SimulationCallersDocument; targetNetworks: string[]; manifestSteps: ManifestStepInput[]; }): SimulationCallersDocument => { const document = normalizeDocument(input.document); if (!Array.isArray(input.targetNetworks) || input.targetNetworks.length === 0) { throw new Error("simulation caller target networks must be a non-empty array"); } const targetNetworks = input.targetNetworks.map((network, index) => ( stringKey(network, `simulation caller target network ${index}`) )); if (new Set(targetNetworks).size !== targetNetworks.length) { throw new Error("Duplicate simulation caller target network"); } const configuredNetworks = Object.keys(document.networks); const expectedSet = [...targetNetworks].sort(); const configuredSet = [...configuredNetworks].sort(); if ( expectedSet.length !== configuredSet.length || expectedSet.some((network, index) => network !== configuredSet[index]) ) { throw new Error("simulation caller target networks do not match configured target networks"); } const manifestSteps = validatedManifestSteps(input.manifestSteps); const manifestStepIds = new Set(manifestSteps.map((step) => step.id)); const networks = record(); for (const network of targetNetworks) { const configured = document.networks[network]; const steps = record(); for (const stepId of Object.keys(configured.steps ?? {})) { if (!manifestStepIds.has(stepId)) { throw new Error( `simulation caller network ${network} references unknown Manifest step ${stepId}`, ); } } for (const step of manifestSteps) { const override = configured.steps?.[step.id]; if (override !== undefined) steps[step.id] = override; } networks[network] = { default: configured.default, ...(Object.keys(steps).length > 0 ? { steps } : {}), }; } return { version: 1, networks }; }; export const buildSimulationCallerNetworkBinding = (input: { document: SimulationCallersDocument; network: string; manifestText: string; manifestSteps: ManifestStepInput[]; }): SimulationCallerNetworkBinding => { const document = normalizeDocument(input.document); const network = stringKey(input.network, "simulation caller binding network"); const configured = document.networks[network]; if (!configured) throw new Error(`Unknown simulation caller network ${network}`); const manifestSteps = validatedManifestSteps(input.manifestSteps); const manifestStepIds = new Set(manifestSteps.map((step) => step.id)); for (const stepId of Object.keys(configured.steps ?? {})) { if (!manifestStepIds.has(stepId)) { throw new Error( `simulation caller network ${network} references unknown Manifest step ${stepId}`, ); } } const resolved: ResolvedSimulationCaller[] = manifestSteps.map((step) => { const callerAddress = configured.steps?.[step.id]; return callerAddress === undefined ? { stepId: step.id, callerAddress: configured.default, source: "network_default" as const, } : { stepId: step.id, callerAddress, source: "step" as const }; }); const exactManifestHash = manifestHash(input.manifestText); const networkHash = hashValue({ version: 1, manifestHash: exactManifestHash, network, default: configured.default.toLowerCase(), resolved: resolved.map((entry) => ({ stepId: entry.stepId, callerAddress: entry.callerAddress.toLowerCase(), })), }); return { network, manifestHash: exactManifestHash, defaultAddress: configured.default, resolved, networkHash, }; }; const simulationCallersPath = (taskDir: string): string => { const realTaskDir = fs.realpathSync(taskDir); const filePath = path.resolve(realTaskDir, "simulation-callers.yaml"); if (path.dirname(filePath) !== realTaskDir) { throw new Error("simulation callers path escapes the task directory"); } if (!fs.existsSync(filePath)) { throw new Error(`Missing simulation callers file: ${filePath}`); } const stat = fs.lstatSync(filePath); if (stat.isSymbolicLink()) { throw new Error("simulation callers file cannot be a symlink"); } if (!stat.isFile()) { throw new Error("simulation callers path must be a regular file"); } const realFilePath = fs.realpathSync(filePath); if (path.dirname(realFilePath) !== realTaskDir) { throw new Error("simulation callers file escapes the task directory"); } return filePath; }; export const loadSimulationCallerNetwork = (input: { taskDir: string; targetNetworks: string[]; network: string; manifestText: string; manifestSteps: ManifestStepInput[]; }): SimulationCallerNetworkBinding => { const document = validateSimulationCallersDocument({ document: parseSimulationCallersYaml( fs.readFileSync(simulationCallersPath(input.taskDir), "utf8"), ), targetNetworks: input.targetNetworks, manifestSteps: input.manifestSteps, }); return buildSimulationCallerNetworkBinding({ document, network: input.network, manifestText: input.manifestText, manifestSteps: input.manifestSteps, }); };