hardhat
Version:
Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.
340 lines (286 loc) • 9.67 kB
text/typescript
import type { GasAnalyticsManager, GasMeasurement } from "./types.js";
import type { TableItemV2 } from "@nomicfoundation/hardhat-utils/format";
import crypto from "node:crypto";
import path from "node:path";
import { formatTableV2 } from "@nomicfoundation/hardhat-utils/format";
import {
ensureDir,
getAllFilesMatching,
readJsonFile,
remove,
writeJsonFile,
} from "@nomicfoundation/hardhat-utils/fs";
import chalk from "chalk";
import debug from "debug";
const gasStatsLog = debug(
"hardhat:core:gas-analytics:gas-analytics-manager:gas-stats",
);
interface ContractGasStats {
deployment?: { gas: number; size: number };
functions: Map<
string, // function name or signature (if overloaded)
GasStats
>;
}
type GasStatsByContract = Map<string, ContractGasStats>;
interface GasStats {
min: number;
max: number;
avg: number;
median: number;
calls: number;
}
type GasMeasurementsByContract = Map<string, ContractGasMeasurements>;
interface ContractGasMeasurements {
deployment?: { gas: number; size: number };
functions: Map<
string, // functionSig
number[]
>;
}
export class GasAnalyticsManagerImplementation implements GasAnalyticsManager {
public gasMeasurements: GasMeasurement[] = [];
readonly #gasStatsPath: string;
constructor(gasStatsRootPath: string) {
this.#gasStatsPath = path.join(gasStatsRootPath, "gas-stats");
}
public addGasMeasurement(gasMeasurement: GasMeasurement): void {
this.gasMeasurements.push(gasMeasurement);
}
public async clearGasMeasurements(id: string): Promise<void> {
const gasMeasurementsPath = await this.#getGasMeasurementsPath(id);
await remove(gasMeasurementsPath);
this.gasMeasurements = [];
gasStatsLog("Cleared gas measurements from disk and memory");
}
public async saveGasMeasurements(id: string): Promise<void> {
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);
}
public async reportGasStats(...ids: string[]): Promise<void> {
await this._loadGasMeasurements(...ids);
const gasStatsByContract = this._calculateGasStats();
const report = this._generateGasStatsReport(gasStatsByContract);
console.log(report);
console.log();
gasStatsLog("Printed markdown report");
}
async #getGasMeasurementsPath(id: string): Promise<string> {
const gasMeasurementsPath = path.join(this.#gasStatsPath, id);
await ensureDir(gasMeasurementsPath);
return gasMeasurementsPath;
}
/**
* @private exposed for testing purposes only
*/
public async _loadGasMeasurements(...ids: string[]): Promise<void> {
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<GasMeasurement[]>(filePath);
for (const entry of entries) {
this.gasMeasurements.push(entry);
}
gasStatsLog("Loaded gas measurements", id, filePath);
}
}
}
/**
* @private exposed for testing purposes only
*/
public _calculateGasStats(): GasStatsByContract {
const gasStatsByContract: GasStatsByContract = new Map();
const measurementsByContract = this._aggregateGasMeasurements();
for (const [contractFqn, measurements] of measurementsByContract) {
const contractGasStats: ContractGasStats = {
functions: new Map(),
};
if (measurements.deployment !== undefined) {
contractGasStats.deployment = {
gas: measurements.deployment.gas,
size: measurements.deployment.size,
};
}
const overloadedFnNames = new Set(
findDuplicates([...measurements.functions.keys()].map(getFunctionName)),
);
for (const [functionSig, gasValues] of measurements.functions) {
const functionName = getFunctionName(functionSig);
const isOverloaded = overloadedFnNames.has(functionName);
const stats: GasStats = {
min: Math.min(...gasValues),
max: Math.max(...gasValues),
avg: roundTo(avg(gasValues), 2),
median: roundTo(median(gasValues), 2),
calls: gasValues.length,
};
contractGasStats.functions.set(
isOverloaded ? functionSig : functionName,
stats,
);
}
gasStatsByContract.set(contractFqn, contractGasStats);
}
return gasStatsByContract;
}
/**
* @private exposed for testing purposes only
*/
public _aggregateGasMeasurements(): GasMeasurementsByContract {
const measurementsByContract: GasMeasurementsByContract = new Map();
for (const currentMeasurement of this.gasMeasurements) {
let contractMeasurements = measurementsByContract.get(
currentMeasurement.contractFqn,
);
if (contractMeasurements === undefined) {
contractMeasurements = {
functions: new Map(),
};
measurementsByContract.set(
currentMeasurement.contractFqn,
contractMeasurements,
);
}
if (currentMeasurement.type === "deployment") {
contractMeasurements.deployment = {
gas: currentMeasurement.gas,
size: currentMeasurement.size,
};
} 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
*/
public _generateGasStatsReport(
gasStatsByContract: GasStatsByContract,
): string {
const rows: TableItemV2[] = [];
if (gasStatsByContract.size > 0) {
rows.push({ type: "title", text: chalk.bold("Gas Usage Statistics") });
}
// Sort contracts alphabetically for consistent output
const sortedContracts = [...gasStatsByContract.entries()].sort(([a], [b]) =>
a.localeCompare(b),
);
for (const [contractFqn, contractGasStats] of sortedContracts) {
rows.push({
type: "section-header",
text: chalk.cyan.bold(getUserFqn(contractFqn)),
});
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.calls}`,
],
});
}
if (contractGasStats.deployment !== undefined) {
rows.push({
type: "header",
cells: ["Deployment Cost", "Deployment Size"].map((s) =>
chalk.yellow(s),
),
});
rows.push({
type: "row",
cells: [
`${contractGasStats.deployment.gas}`,
`${contractGasStats.deployment.size}`,
],
});
}
}
return formatTableV2(rows);
}
}
export function avg(values: number[]): number {
return values.reduce((a, c) => a + c, 0) / values.length;
}
export function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
}
export function getUserFqn(inputFqn: string): string {
if (inputFqn.startsWith("project/")) {
return inputFqn.slice("project/".length);
}
if (inputFqn.startsWith("npm/")) {
const withoutPrefix = inputFqn.slice("npm/".length);
// Match "<pkg>@<version>/<rest>", where <pkg> may be scoped (@scope/pkg)
const match = withoutPrefix.match(/^(@?[^@/]+(?:\/[^@/]+)*)@[^/]+\/(.*)$/);
if (match !== null) {
return `${match[1]}/${match[2]}`;
}
return withoutPrefix;
}
return inputFqn;
}
export function getFunctionName(signature: string): string {
return signature.split("(")[0];
}
export function findDuplicates<T>(arr: T[]): T[] {
const seen = new Set<T>();
const duplicates = new Set<T>();
for (const item of arr) {
if (seen.has(item)) {
duplicates.add(item);
} else {
seen.add(item);
}
}
return [...duplicates];
}
export function roundTo(value: number, decimals: number): number {
const factor = 10 ** decimals;
return Math.round(value * factor) / factor;
}