UNPKG

hardhat

Version:

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

474 lines (404 loc) 12.9 kB
import type { SuiteResult } from "@nomicfoundation/edr"; 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 interface FunctionGasSnapshot { contractNameOrFqn: string; functionSig: string; gasUsage: StandardTestKindGasUsage | FuzzTestKindGasUsage; } export interface FunctionGasSnapshotWithMetadata extends FunctionGasSnapshot { metadata: { source: string; }; } export interface StandardTestKindGasUsage { kind: "standard"; gas: bigint; } export interface FuzzTestKindGasUsage { kind: "fuzz"; runs: bigint; meanGas: bigint; medianGas: bigint; } export interface FunctionGasSnapshotComparison { added: FunctionGasSnapshot[]; removed: FunctionGasSnapshot[]; changed: FunctionGasSnapshotChange[]; } export interface FunctionGasSnapshotChange { source: string; contractNameOrFqn: string; functionSig: string; kind: "standard" | "fuzz"; expected: number; actual: number; runs?: number; } export interface FunctionGasSnapshotCheckResult { passed: boolean; comparison: FunctionGasSnapshotComparison; written: boolean; } export function getFunctionGasSnapshotsPath(basePath: string): string { return path.join(basePath, FUNCTION_GAS_SNAPSHOTS_FILE); } export function extractFunctionGasSnapshots( suiteResults: SuiteResult[], ): FunctionGasSnapshotWithMetadata[] { const duplicateContractNames = findDuplicates( suiteResults.map(({ id }) => id.name), ); const snapshots: FunctionGasSnapshotWithMetadata[] = []; 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" as const, gas: testKind.consumedGas, } : { kind: "fuzz" as const, 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: string, snapshots: FunctionGasSnapshot[], ): Promise<void> { 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: string, ): Promise<FunctionGasSnapshot[]> { const snapshotsPath = getFunctionGasSnapshotsPath(basePath); let stringifiedSnapshots: string; 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: FunctionGasSnapshot[], ): string { const lines: string[] = []; 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: string, ): FunctionGasSnapshot[] { if (stringifiedSnapshots.trim() === "") { return []; } const lines = stringifiedSnapshots.split("\n"); const snapshots: FunctionGasSnapshot[] = []; 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: FunctionGasSnapshot[], currentSnapshots: FunctionGasSnapshotWithMetadata[], ): FunctionGasSnapshotComparison { const previousSnapshotsMap = new Map( previousSnapshots.map((s) => [ `${s.contractNameOrFqn}#${s.functionSig}`, s, ]), ); const added: FunctionGasSnapshot[] = []; const changed: FunctionGasSnapshotChange[] = []; 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: StandardTestKindGasUsage | FuzzTestKindGasUsage, current: StandardTestKindGasUsage | FuzzTestKindGasUsage, ): boolean { 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: string, suiteResults: SuiteResult[], ): Promise<FunctionGasSnapshotCheckResult> { const functionGasSnapshots = extractFunctionGasSnapshots(suiteResults); let previousFunctionGasSnapshots: FunctionGasSnapshot[]; 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: FunctionGasSnapshotCheckResult, logger: typeof console.log = console.log, ): void { 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: FunctionGasSnapshotChange[], logger: typeof console.log = console.log, ): void { 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(); } } }