hardhat
Version:
Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.
297 lines • 13.4 kB
JavaScript
import crypto from "node:crypto";
import path from "node:path";
import { HardhatError, assertHardhatInvariant, } from "@nomicfoundation/hardhat-errors";
import { formatTable } from "@nomicfoundation/hardhat-utils/format";
import { ensureDir, exists, getAllFilesMatching, isDirectory, readJsonFile, remove, writeJsonFile, } from "@nomicfoundation/hardhat-utils/fs";
import { findDuplicates } from "@nomicfoundation/hardhat-utils/lang";
import chalk from "chalk";
import debug from "debug";
import { parseFullyQualifiedName } from "../../../utils/contract-names.js";
import { avg, getDisplayKey, getFunctionName, getProxyLabel, getUserFqn, makeGroupKey, median, } from "./helpers/utils.js";
const gasStatsLog = debug("hardhat:core:gas-analytics:gas-analytics-manager:gas-stats");
export class GasAnalyticsManagerImplementation {
gasMeasurements = [];
#gasStatsPath;
#reportEnabled = true;
constructor(gasStatsRootPath) {
this.#gasStatsPath = path.join(gasStatsRootPath, "gas-stats");
}
addGasMeasurement(gasMeasurement) {
this.gasMeasurements.push(gasMeasurement);
}
async clearGasMeasurements(id) {
const gasMeasurementsPath = await this.#getGasMeasurementsPath(id);
await remove(gasMeasurementsPath);
this.gasMeasurements = [];
gasStatsLog("Cleared gas measurements from disk and memory");
}
async saveGasMeasurements(id) {
const gasMeasurementsPath = await this.#getGasMeasurementsPath(id);
const filePath = path.join(gasMeasurementsPath, `${crypto.randomUUID()}.json`);
await writeJsonFile(filePath, this.gasMeasurements);
gasStatsLog("Saved gas measurements", id, filePath);
}
async reportGasStats(...ids) {
if (!this.#reportEnabled) {
return;
}
await this._loadGasMeasurements(...ids);
const gasStatsByContract = this._calculateGasStats();
const report = this._generateGasStatsReport(gasStatsByContract);
console.log(report);
console.log();
gasStatsLog("Printed markdown report");
}
async writeGasStatsJson(outputPath, ...ids) {
if (!this.#reportEnabled) {
return;
}
await this._loadGasMeasurements(...ids);
const gasStatsByContract = this._calculateGasStats();
const resolvedPath = path.resolve(outputPath);
if ((await exists(resolvedPath)) && (await isDirectory(resolvedPath))) {
throw new HardhatError(HardhatError.ERRORS.CORE.BUILTIN_TASKS.INVALID_FILE_PATH, { path: outputPath });
}
await ensureDir(path.dirname(resolvedPath));
const json = this._generateGasStatsJson(gasStatsByContract);
await writeJsonFile(resolvedPath, json);
gasStatsLog("Written gas stats JSON to", resolvedPath);
}
enableReport() {
this.#reportEnabled = true;
}
disableReport() {
this.#reportEnabled = false;
}
async #getGasMeasurementsPath(id) {
const gasMeasurementsPath = path.join(this.#gasStatsPath, id);
await ensureDir(gasMeasurementsPath);
return gasMeasurementsPath;
}
/**
* @private exposed for testing purposes only
*/
async _loadGasMeasurements(...ids) {
this.gasMeasurements = [];
for (const id of ids) {
const gasMeasurementsPath = await this.#getGasMeasurementsPath(id);
const filePaths = await getAllFilesMatching(gasMeasurementsPath);
for (const filePath of filePaths) {
const entries = await readJsonFile(filePath);
for (const entry of entries) {
this.gasMeasurements.push(entry);
}
gasStatsLog("Loaded gas measurements", id, filePath);
}
}
}
/**
* @private exposed for testing purposes only
*/
_calculateGasStats() {
const gasStatsByContract = new Map();
const measurementsByContract = this._aggregateGasMeasurements();
for (const [groupKey, measurements] of measurementsByContract) {
const contractGasStats = {
proxyChain: measurements.proxyChain,
functions: new Map(),
};
if (measurements.deployments.length > 0) {
assertHardhatInvariant(measurements.deploymentRuntimeSize !== undefined, "deploymentRuntimeSize must be set when deployments exist");
contractGasStats.deployment = {
min: Math.min(...measurements.deployments),
max: Math.max(...measurements.deployments),
avg: Math.round(avg(measurements.deployments)),
median: Math.round(median(measurements.deployments)),
count: measurements.deployments.length,
runtimeSize: measurements.deploymentRuntimeSize,
};
}
const overloadedFnNames = findDuplicates([...measurements.functions.keys()].map(getFunctionName));
for (const [functionSig, gasValues] of measurements.functions) {
const functionName = getFunctionName(functionSig);
const isOverloaded = overloadedFnNames.has(functionName);
const stats = {
min: Math.min(...gasValues),
max: Math.max(...gasValues),
avg: Math.round(avg(gasValues)),
median: Math.round(median(gasValues)),
count: gasValues.length,
};
contractGasStats.functions.set(isOverloaded ? functionSig : functionName, stats);
}
gasStatsByContract.set(groupKey, contractGasStats);
}
// Duplicate deployment stats from direct-call groups to proxied groups
for (const [groupKey, stats] of gasStatsByContract) {
if (stats.proxyChain.length > 0 && stats.deployment === undefined) {
// Extract contractFqn from the groupKey (everything before the first \0)
const contractFqn = groupKey.split("\0")[0];
const directKey = makeGroupKey(contractFqn, []);
const directStats = gasStatsByContract.get(directKey);
if (directStats?.deployment !== undefined) {
stats.deployment = directStats.deployment;
}
}
}
return gasStatsByContract;
}
/**
* @private exposed for testing purposes only
*/
_aggregateGasMeasurements() {
const measurementsByContract = new Map();
for (const currentMeasurement of this.gasMeasurements) {
const proxyChain = currentMeasurement.type === "function"
? currentMeasurement.proxyChain
: [];
const groupKey = makeGroupKey(currentMeasurement.contractFqn, proxyChain);
let contractMeasurements = measurementsByContract.get(groupKey);
if (contractMeasurements === undefined) {
contractMeasurements = {
proxyChain,
deployments: [],
functions: new Map(),
};
measurementsByContract.set(groupKey, contractMeasurements);
}
if (currentMeasurement.type === "deployment") {
contractMeasurements.deployments.push(currentMeasurement.gas);
if (contractMeasurements.deploymentRuntimeSize === undefined) {
contractMeasurements.deploymentRuntimeSize =
currentMeasurement.runtimeSize;
}
}
else {
let measurements = contractMeasurements.functions.get(currentMeasurement.functionSig);
if (measurements === undefined) {
measurements = [];
contractMeasurements.functions.set(currentMeasurement.functionSig, measurements);
}
measurements.push(currentMeasurement.gas);
}
}
return measurementsByContract;
}
/**
* @private exposed for testing purposes only
*/
_generateGasStatsReport(gasStatsByContract) {
const rows = [];
if (gasStatsByContract.size > 0) {
rows.push({ type: "title", text: chalk.bold("Gas Usage Statistics") });
}
const sortedContracts = getSortedContractEntries(gasStatsByContract);
for (const { userFqn, proxyLabel, stats: contractGasStats, } of sortedContracts) {
rows.push({
type: "section-header",
text: chalk.cyan.bold(userFqn),
subtitle: proxyLabel !== undefined ? chalk.cyan(proxyLabel) : undefined,
});
if (contractGasStats.functions.size > 0) {
rows.push({
type: "header",
cells: [
"Function name",
"Min",
"Average",
"Median",
"Max",
"#calls",
].map((s) => chalk.yellow(s)),
});
}
// Sort functions by removing trailing ) and comparing alphabetically.
// This ensures that overloaded functions with fewer params come first
// (e.g., foo(uint256) comes before foo(uint256,uint256)). In other
// scenarios, removing the trailing ) has no effect on the order.
const sortedFunctions = [...contractGasStats.functions.entries()].sort(([a], [b]) => a.split(")")[0].localeCompare(b.split(")")[0]));
for (const [functionDisplayName, gasStats] of sortedFunctions) {
rows.push({
type: "row",
cells: [
functionDisplayName,
`${gasStats.min}`,
`${gasStats.avg}`,
`${gasStats.median}`,
`${gasStats.max}`,
`${gasStats.count}`,
],
});
}
if (contractGasStats.deployment !== undefined) {
rows.push({
type: "header",
cells: [
"Deployment",
"Min",
"Average",
"Median",
"Max",
"#deployments",
].map((s) => chalk.yellow(s)),
});
rows.push({
type: "row",
cells: [
"",
`${contractGasStats.deployment.min}`,
`${contractGasStats.deployment.avg}`,
`${contractGasStats.deployment.median}`,
`${contractGasStats.deployment.max}`,
`${contractGasStats.deployment.count}`,
],
});
rows.push({
type: "header",
cells: [
chalk.yellow("Bytecode size"),
`${contractGasStats.deployment.runtimeSize}`,
],
});
}
}
return formatTable(rows);
}
/**
* @private exposed for testing purposes only
*/
_generateGasStatsJson(gasStatsByContract) {
const sortedContracts = getSortedContractEntries(gasStatsByContract);
const contracts = {};
for (const { userFqn, displayKey, stats } of sortedContracts) {
const { sourceName, contractName } = parseFullyQualifiedName(userFqn);
const deployment = stats.deployment !== undefined ? { ...stats.deployment } : null;
let functions = null;
if (stats.functions.size > 0) {
functions = {};
// Sort functions by removing trailing ) and comparing alphabetically.
// This ensures that overloaded functions with fewer params come first
// (e.g., foo(uint256) comes before foo(uint256,uint256)). In other
// scenarios, removing the trailing ) has no effect on the order.
const sortedFunctions = [...stats.functions.entries()].sort(([a], [b]) => a.split(")")[0].localeCompare(b.split(")")[0]));
functions = Object.fromEntries(sortedFunctions);
}
contracts[displayKey] = {
sourceName,
contractName,
proxyChain: stats.proxyChain.map(getUserFqn),
deployment,
functions,
};
}
return { contracts };
}
}
function getSortedContractEntries(gasStatsByContract) {
return [...gasStatsByContract.entries()]
.map(([groupKey, stats]) => {
const contractFqn = groupKey.split("\0")[0];
const userFqn = getUserFqn(contractFqn);
const displayKey = getDisplayKey(userFqn, stats.proxyChain);
const proxyLabel = getProxyLabel(stats.proxyChain);
return { userFqn, displayKey, proxyLabel, stats };
})
.sort((a, b) => a.displayKey.localeCompare(b.displayKey));
}
//# sourceMappingURL=gas-analytics-manager.js.map