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
JavaScript
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