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
text/typescript
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();
}
}
}