UNPKG

hardhat

Version:

Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.

288 lines 11.8 kB
import path from "node:path"; import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { ensureError } from "@nomicfoundation/hardhat-utils/error"; import { FileNotFoundError, readUtf8File, writeUtf8File, } from "@nomicfoundation/hardhat-utils/fs"; import { findDuplicates } from "@nomicfoundation/hardhat-utils/lang"; import chalk from "chalk"; import { getFullyQualifiedName, parseFullyQualifiedName, } from "../../../utils/contract-names.js"; import { getUserFqn } from "./gas-analytics-manager.js"; import { formatSectionHeader } from "./helpers.js"; export const FUNCTION_GAS_SNAPSHOTS_FILE = ".gas-snapshot"; export function getFunctionGasSnapshotsPath(basePath) { return path.join(basePath, FUNCTION_GAS_SNAPSHOTS_FILE); } export function extractFunctionGasSnapshots(suiteResults) { const duplicateContractNames = findDuplicates(suiteResults.map(({ id }) => id.name)); const snapshots = []; for (const { id: suiteId, testResults } of suiteResults) { for (const { name: functionSig, kind: testKind } of testResults) { if ("calls" in testKind) { continue; } const userFqn = getUserFqn(getFullyQualifiedName(suiteId.source, suiteId.name)); const contractNameOrFqn = duplicateContractNames.has(suiteId.name) ? userFqn : suiteId.name; const gasUsage = "consumedGas" in testKind ? { kind: "standard", gas: testKind.consumedGas, } : { kind: "fuzz", runs: testKind.runs, meanGas: testKind.meanGas, medianGas: testKind.medianGas, }; snapshots.push({ contractNameOrFqn, functionSig, gasUsage, metadata: { source: parseFullyQualifiedName(userFqn).sourceName, }, }); } } return snapshots; } export async function writeFunctionGasSnapshots(basePath, snapshots) { const snapshotsPath = getFunctionGasSnapshotsPath(basePath); try { await writeUtf8File(snapshotsPath, stringifyFunctionGasSnapshots(snapshots)); } catch (error) { ensureError(error); throw new HardhatError(HardhatError.ERRORS.CORE.SOLIDITY_TESTS.SNAPSHOT_WRITE_ERROR, { snapshotsPath, error: error.message }, error); } } export async function readFunctionGasSnapshots(basePath) { const snapshotsPath = getFunctionGasSnapshotsPath(basePath); let stringifiedSnapshots; try { stringifiedSnapshots = await readUtf8File(snapshotsPath); } catch (error) { ensureError(error); // Re-throw as-is to allow the caller to handle this case specifically if (error instanceof FileNotFoundError) { throw error; } throw new HardhatError(HardhatError.ERRORS.CORE.SOLIDITY_TESTS.SNAPSHOT_READ_ERROR, { snapshotsPath, error: error.message }, error); } return parseFunctionGasSnapshots(stringifiedSnapshots); } export function stringifyFunctionGasSnapshots(snapshots) { const lines = []; for (const { contractNameOrFqn, functionSig, gasUsage } of snapshots) { const gasDetails = gasUsage.kind === "standard" ? `gas: ${gasUsage.gas}` : `runs: ${gasUsage.runs}, μ: ${gasUsage.meanGas}, ~: ${gasUsage.medianGas}`; lines.push(`${contractNameOrFqn}#${functionSig} (${gasDetails})`); } return lines.sort((a, b) => a.localeCompare(b)).join("\n"); } export function parseFunctionGasSnapshots(stringifiedSnapshots) { if (stringifiedSnapshots.trim() === "") { return []; } const lines = stringifiedSnapshots.split("\n"); const snapshots = []; const standardTestRegex = /^(.+)#(.+) \(gas: (\d+)\)$/; const fuzzTestRegex = /^(.+)#(.+) \(runs: (\d+), μ: (\d+), ~: (\d+)\)$/; for (const line of lines) { if (line.trim() === "") { continue; } const standardMatch = standardTestRegex.exec(line); if (standardMatch !== null) { const [, contractNameOrFqn, functionSig, gasValue] = standardMatch; snapshots.push({ contractNameOrFqn, functionSig, gasUsage: { kind: "standard", gas: BigInt(gasValue) }, }); continue; } const fuzzMatch = fuzzTestRegex.exec(line); if (fuzzMatch !== null) { const [, contractNameOrFqn, functionSig, runs, meanGas, medianGas] = fuzzMatch; snapshots.push({ contractNameOrFqn, functionSig, gasUsage: { kind: "fuzz", runs: BigInt(runs), meanGas: BigInt(meanGas), medianGas: BigInt(medianGas), }, }); continue; } throw new HardhatError(HardhatError.ERRORS.CORE.SOLIDITY_TESTS.INVALID_SNAPSHOT_FORMAT, { file: FUNCTION_GAS_SNAPSHOTS_FILE, line, expectedFormat: "'ContractName#functionName (gas: value)' for standard tests or 'ContractName#functionName (runs: value, μ: value, ~: value)' for fuzz tests", }); } return snapshots; } export function compareFunctionGasSnapshots(previousSnapshots, currentSnapshots) { const previousSnapshotsMap = new Map(previousSnapshots.map((s) => [ `${s.contractNameOrFqn}#${s.functionSig}`, s, ])); const added = []; const changed = []; for (const current of currentSnapshots) { const key = `${current.contractNameOrFqn}#${current.functionSig}`; const previous = previousSnapshotsMap.get(key); const currentKind = current.gasUsage.kind; const previousKind = previous?.gasUsage.kind; if (previous === undefined || // If the kind doesn't match, we treat it as an addition + removal previousKind !== currentKind) { added.push(current); continue; } if (hasGasUsageChanged(previous.gasUsage, current.gasUsage)) { const expectedValue = previousKind === "standard" ? previous.gasUsage.gas : previous.gasUsage.medianGas; const actualValue = currentKind === "standard" ? current.gasUsage.gas : current.gasUsage.medianGas; changed.push({ contractNameOrFqn: current.contractNameOrFqn, functionSig: current.functionSig, kind: currentKind, expected: Number(expectedValue), actual: Number(actualValue), runs: currentKind === "fuzz" ? Number(current.gasUsage.runs) : undefined, source: current.metadata.source, }); } previousSnapshotsMap.delete(key); } const removed = Array.from(previousSnapshotsMap.values()); return { added, removed, changed }; } export function hasGasUsageChanged(previous, current) { if (previous.kind === "standard" && current.kind === "standard") { return previous.gas !== current.gas; } if (previous.kind === "fuzz" && current.kind === "fuzz") { return previous.medianGas !== current.medianGas; } return false; } export async function checkFunctionGasSnapshots(basePath, suiteResults) { const functionGasSnapshots = extractFunctionGasSnapshots(suiteResults); let previousFunctionGasSnapshots; try { previousFunctionGasSnapshots = await readFunctionGasSnapshots(basePath); } catch (error) { if (error instanceof FileNotFoundError) { await writeFunctionGasSnapshots(basePath, functionGasSnapshots); return { passed: true, comparison: { added: [], removed: [], changed: [], }, written: true, }; } throw error; } const comparison = compareFunctionGasSnapshots(previousFunctionGasSnapshots, functionGasSnapshots); // Update snapshots when functions are added or removed (but not changed) const hasAddedOrRemoved = comparison.added.length > 0 || comparison.removed.length > 0; if (comparison.changed.length === 0 && hasAddedOrRemoved) { await writeFunctionGasSnapshots(basePath, functionGasSnapshots); } return { passed: comparison.changed.length === 0, comparison, written: hasAddedOrRemoved, }; } export function logFunctionGasSnapshotsSection(result, logger = console.log) { const { comparison, written } = result; const changedLength = comparison.changed.length; const addedLength = comparison.added.length; const removedLength = comparison.removed.length; const hasChanges = changedLength > 0; const hasAdded = addedLength > 0; const hasRemoved = removedLength > 0; const hasAnyDifferences = hasChanges || hasAdded || hasRemoved; const isFirstTimeWrite = written && !hasAnyDifferences; // Nothing to report if (!isFirstTimeWrite && !hasAnyDifferences) { return; } logger(formatSectionHeader("Function gas snapshots", { changedLength, addedLength, removedLength, })); if (isFirstTimeWrite) { logger(); logger(chalk.green(" No existing snapshots found. Function gas snapshots written successfully")); logger(); return; } if (hasChanges) { logger(); printFunctionGasSnapshotChanges(comparison.changed, logger); } if (hasAdded) { logger(); logger(` Added ${comparison.added.length} function(s):`); const addedLines = stringifyFunctionGasSnapshots(comparison.added).split("\n"); for (const line of addedLines) { logger(chalk.green(` + ${line}`)); } } if (hasRemoved) { logger(); logger(` Removed ${comparison.removed.length} function(s):`); const removedLines = stringifyFunctionGasSnapshots(comparison.removed).split("\n"); for (const line of removedLines) { logger(chalk.red(` - ${line}`)); } } logger(); } export function printFunctionGasSnapshotChanges(changes, logger = console.log) { for (let i = 0; i < changes.length; i++) { const change = changes[i]; const isLast = i === changes.length - 1; logger(` ${change.contractNameOrFqn}#${change.functionSig}`); logger(chalk.grey(` (in ${change.source})`)); if (change.kind === "fuzz") { logger(chalk.grey(` Runs: ${change.runs}`)); } const diff = change.actual - change.expected; const formattedDiff = diff > 0 ? `Δ+${diff}` : ${diff}`; let gasChange = `${formattedDiff}`; if (change.expected > 0) { const percent = (diff / change.expected) * 100; const formattedPercent = percent >= 0 ? `+${percent.toFixed(2)}%` : `${percent.toFixed(2)}%`; gasChange = `${formattedPercent}, ${formattedDiff}`; } // Color: green for decrease (improvement), red for increase (regression) const formattedGasChange = diff < 0 ? chalk.green(gasChange) : chalk.red(gasChange); const label = change.kind === "fuzz" ? "~" : "gas"; logger(chalk.grey(` Expected (${label}): ${change.expected}`)); logger(chalk.grey(` Actual (${label}): ${change.actual} (`) + formattedGasChange + chalk.grey(")")); if (!isLast) { logger(); } } } //# sourceMappingURL=function-gas-snapshots.js.map