diff --git a/.changeset/seven-insects-move.md b/.changeset/seven-insects-move.md new file mode 100644 index 00000000000..bbf4e78a193 --- /dev/null +++ b/.changeset/seven-insects-move.md @@ -0,0 +1,6 @@ +--- +"@nomicfoundation/hardhat-utils": patch +"hardhat": patch +--- + +Show proxy chain information in --gas-stats and --gas-stats-json output diff --git a/packages/example-project/contracts/Proxies.sol b/packages/example-project/contracts/Proxies.sol new file mode 100644 index 00000000000..0e0e8f0536b --- /dev/null +++ b/packages/example-project/contracts/Proxies.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {console} from "hardhat/console.sol"; + +// We need to define two different proxies so that their implementation +// storage slots are different, so we can chain them. +abstract contract BaseProxy { + fallback() external payable { + address impl = getImplementation(); + assembly { + calldatacopy(0, 0, calldatasize()) + let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0) + returndatacopy(0, 0, returndatasize()) + switch result + case 0 { + revert(0, returndatasize()) + } + default { + return(0, returndatasize()) + } + } + } + + function getImplementation() internal view virtual returns (address); + + receive() external payable {} +} + +contract Proxy is BaseProxy { + address implementation; + + constructor(address _impl) { + // console.log("Setting implementation for Proxy:", _impl); + implementation = _impl; + } + + function getImplementation() internal view override returns (address) { + // console.log("Getting implementation for Proxy:", implementation); + return implementation; + } +} + +contract Proxy2 is BaseProxy { + // `implementation` occupies slot 0 so that when Proxy's code runs via + // delegatecall in Proxy2's storage context, reading slot 0 returns the + // actual implementation address. + address implementation; + address proxy1; + + constructor(address _impl, address _proxy1) { + // console.log("Setting implementation for Proxy2:", _impl); + proxy1 = _proxy1; + implementation = _impl; + } + + function getImplementation() internal view override returns (address) { + // console.log("Getting implementation for Proxy2:", proxy1); + return proxy1; + } +} + +contract Impl1 { + function one() external returns (uint256) { + return 1; + } +} + +contract Impl2 { + function two() external returns (uint256) { + return 2; + } +} diff --git a/packages/example-project/test/contracts/Proxies.t.sol b/packages/example-project/test/contracts/Proxies.t.sol new file mode 100644 index 00000000000..d4abcfd8144 --- /dev/null +++ b/packages/example-project/test/contracts/Proxies.t.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import "../../contracts/Proxies.sol"; + +contract ProxiesTest is Test { + function test_ShouldTrackProxiedCallsToImpl1AndImpl2AsSeparate() public { + Impl1 impl1 = new Impl1(); + Impl2 impl2 = new Impl2(); + + Proxy proxy1 = new Proxy(address(impl1)); + Proxy proxy2 = new Proxy(address(impl2)); + + Impl1 i1 = Impl1(address(proxy1)); + Impl2 i2 = Impl2(address(proxy2)); + + // emit log("Calling Proxy -> Impl1"); + i1.one(); + + // emit log("Calling Proxy -> Impl2"); + i2.two(); + + // emit log("Calling Impl1"); + impl1.one(); + } + + function test_ShouldTrackProxiedCallsToImpl1AsSeparateWithDifferentProxyChains() + public + { + // We use the same impl but different proxy chains + Impl1 impl1 = new Impl1(); + + Proxy proxy1 = new Proxy(address(impl1)); + Proxy proxy2 = new Proxy(address(impl1)); + + // We use a proxy in front of proxy1 + Proxy2 proxy11 = new Proxy2(address(impl1), address(proxy1)); + + Impl1 i1 = Impl1(address(proxy11)); + Impl1 i2 = Impl1(address(proxy2)); + + // emit log("Calling Proxy2 -> Proxy -> Impl1"); + i1.one(); + + // emit log("Calling Proxy1 -> Impl1"); + i2.one(); + } +} diff --git a/packages/example-project/test/node/Proxies.ts b/packages/example-project/test/node/Proxies.ts new file mode 100644 index 00000000000..053e55e77f5 --- /dev/null +++ b/packages/example-project/test/node/Proxies.ts @@ -0,0 +1,49 @@ +import { describe, it } from "node:test"; + +import { network } from "hardhat"; + +describe("Proxies", async function () { + const { viem } = await network.connect(); + + it("Should track the proxied calls to Impl1 and Impl2 as separate, despite using the same proxy", async function () { + const impl1 = await viem.deployContract("Impl1"); + const impl2 = await viem.deployContract("Impl2"); + + const proxy1 = await viem.deployContract("Proxy", [impl1.address]); + const proxy2 = await viem.deployContract("Proxy", [impl2.address]); + + const i1 = await viem.getContractAt("Impl1", proxy1.address); + const i2 = await viem.getContractAt("Impl2", proxy2.address); + + // console.log("Calling Proxy -> Impl1"); + await i1.write.one(); + + // console.log("Calling Proxy -> Impl2"); + await i2.write.two(); + + // console.log("Calling Impl1"); + await impl1.write.one(); + }); + + it("Should track the proxied calls to Impl1 as separate if they use separate proxy chains", async function () { + // We use the same impl but different proxy chains + const impl1 = await viem.deployContract("Impl1"); + + const proxy1 = await viem.deployContract("Proxy", [impl1.address]); + const proxy2 = await viem.deployContract("Proxy", [impl1.address]); + + // We use a proxy in front of Proxy1 + const proxy11 = await viem.deployContract("Proxy2", [ + impl1.address, + proxy1.address, + ]); + + const i1 = await viem.getContractAt("Impl1", proxy11.address); + const i2 = await viem.getContractAt("Impl1", proxy2.address); + + // console.log("Calling Proxy2 -> Proxy -> Impl1"); + await i1.write.one(); + // console.log("Calling Proxy1 -> Impl1"); + await i2.write.one(); + }); +}); diff --git a/packages/hardhat-utils/src/format.ts b/packages/hardhat-utils/src/format.ts index 6594610b17f..28841ca4e69 100644 --- a/packages/hardhat-utils/src/format.ts +++ b/packages/hardhat-utils/src/format.ts @@ -17,6 +17,7 @@ export interface TableTitle { export interface TableSectionHeader { type: "section-header"; text: string; + subtitle?: string; } export interface TableHeader { @@ -138,6 +139,15 @@ export function formatTable(items: TableItem[]): string { tableWidth - 2 + (headerActualLength - headerDisplayWidth), ); lines.push("║ " + paddedHeader + " ║"); + if (current.subtitle !== undefined) { + const subtitleText = " " + current.subtitle; + const subtitleDisplayWidth = getStringWidth(subtitleText); + const subtitleActualLength = subtitleText.length; + const paddedSubtitle = subtitleText.padEnd( + tableWidth - 2 + (subtitleActualLength - subtitleDisplayWidth), + ); + lines.push("║ " + paddedSubtitle + " ║"); + } inSection = true; } else if (current.type === "header") { const currentCellCount = current.cells.length; diff --git a/packages/hardhat-utils/src/internal/format.ts b/packages/hardhat-utils/src/internal/format.ts index 9c3a9eb726f..82725c170f9 100644 --- a/packages/hardhat-utils/src/internal/format.ts +++ b/packages/hardhat-utils/src/internal/format.ts @@ -59,6 +59,10 @@ export function getHeadingWidth(items: TableItem[]): number { if (item.type === "section-header" || item.type === "title") { headingWidth = Math.max(headingWidth, getStringWidth(item.text) + 2); } + if (item.type === "section-header" && item.subtitle !== undefined) { + // +4 accounts for "║ " prefix (2) and " " indent (2) + headingWidth = Math.max(headingWidth, getStringWidth(item.subtitle) + 4); + } } return headingWidth; } diff --git a/packages/hardhat-utils/test/format.ts b/packages/hardhat-utils/test/format.ts index 53b4e7bae4e..6bd749d2058 100644 --- a/packages/hardhat-utils/test/format.ts +++ b/packages/hardhat-utils/test/format.ts @@ -286,5 +286,57 @@ describe("format", () => { ].join("\n"), ); }); + + it("Should render section header with subtitle on a second line", () => { + const result = formatTable([ + { + type: "section-header", + text: "Contract Name", + subtitle: "(via Proxy)", + }, + { type: "header", cells: ["Function", "Gas"] }, + { type: "row", cells: ["transfer", "25000"] }, + ]); + + assert.equal( + result, + [ + "╔══════════════════╗", + "║ Contract Name ║", + "║ (via Proxy) ║", + "╟──────────┬───────╢", + "║ Function │ Gas ║", + "╟──────────┼───────╢", + "║ transfer │ 25000 ║", + "╚══════════╧═══════╝", + ].join("\n"), + ); + }); + + it("Should expand table when subtitle is wider than content", () => { + const result = formatTable([ + { + type: "section-header", + text: "Impl", + subtitle: "(via Proxy2 → Proxy1)", + }, + { type: "header", cells: ["A", "B"] }, + { type: "row", cells: ["1", "2"] }, + ]); + + assert.equal( + result, + [ + "╔═════════════════════════╗", + "║ Impl ║", + "║ (via Proxy2 → Proxy1) ║", + "╟───┬─────────────────────╢", + "║ A │ B ║", + "╟───┼─────────────────────╢", + "║ 1 │ 2 ║", + "╚═══╧═════════════════════╝", + ].join("\n"), + ); + }); }); }); diff --git a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/exports.ts b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/exports.ts index 021edb014d4..4aac096bdf5 100644 --- a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/exports.ts +++ b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/exports.ts @@ -2,4 +2,4 @@ export { markTestRunStart, markTestRunDone, markTestWorkerDone, -} from "./helpers.js"; +} from "./helpers/compat.js"; diff --git a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/function-gas-snapshots.ts b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/function-gas-snapshots.ts index c5dd14480b6..83026bd830d 100644 --- a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/function-gas-snapshots.ts +++ b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/function-gas-snapshots.ts @@ -17,8 +17,7 @@ import { parseFullyQualifiedName, } from "../../../utils/contract-names.js"; -import { getUserFqn } from "./gas-analytics-manager.js"; -import { formatSectionHeader } from "./helpers.js"; +import { formatSectionHeader, getUserFqn } from "./helpers/utils.js"; export const FUNCTION_GAS_SNAPSHOTS_FILE = ".gas-snapshot"; diff --git a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts index 46652b12725..96c6131b04a 100644 --- a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts +++ b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts @@ -31,6 +31,16 @@ 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", ); @@ -40,6 +50,7 @@ interface DeploymentGasStats extends GasStats { } interface ContractGasStats { + proxyChain: string[]; deployment?: DeploymentGasStats; functions: Map< string, // function name or signature (if overloaded) @@ -60,6 +71,7 @@ interface GasStats { type GasMeasurementsByContract = Map; interface ContractGasMeasurements { + proxyChain: string[]; deployments: number[]; deploymentRuntimeSize?: number; functions: Map< @@ -179,8 +191,9 @@ export class GasAnalyticsManagerImplementation implements GasAnalyticsManager { const gasStatsByContract: GasStatsByContract = new Map(); const measurementsByContract = this._aggregateGasMeasurements(); - for (const [contractFqn, measurements] of measurementsByContract) { + for (const [groupKey, measurements] of measurementsByContract) { const contractGasStats: ContractGasStats = { + proxyChain: measurements.proxyChain, functions: new Map(), }; @@ -222,7 +235,20 @@ export class GasAnalyticsManagerImplementation implements GasAnalyticsManager { ); } - gasStatsByContract.set(contractFqn, contractGasStats); + 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; @@ -235,18 +261,20 @@ export class GasAnalyticsManagerImplementation implements GasAnalyticsManager { const measurementsByContract: GasMeasurementsByContract = new Map(); for (const currentMeasurement of this.gasMeasurements) { - let contractMeasurements = measurementsByContract.get( - currentMeasurement.contractFqn, - ); + 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( - currentMeasurement.contractFqn, - contractMeasurements, - ); + measurementsByContract.set(groupKey, contractMeasurements); } if (currentMeasurement.type === "deployment") { @@ -286,15 +314,16 @@ export class GasAnalyticsManagerImplementation implements GasAnalyticsManager { 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) { + const sortedContracts = getSortedContractEntries(gasStatsByContract); + for (const { + userFqn, + proxyLabel, + stats: contractGasStats, + } of sortedContracts) { rows.push({ type: "section-header", - text: chalk.cyan.bold(getUserFqn(contractFqn)), + text: chalk.cyan.bold(userFqn), + subtitle: proxyLabel !== undefined ? chalk.cyan(proxyLabel) : undefined, }); if (contractGasStats.functions.size > 0) { @@ -375,16 +404,10 @@ export class GasAnalyticsManagerImplementation implements GasAnalyticsManager { public _generateGasStatsJson( gasStatsByContract: GasStatsByContract, ): GasStatsJson { - const sortedContracts = [...gasStatsByContract.entries()] - .map(([internalFqn, stats]) => ({ - userFqn: getUserFqn(internalFqn), - stats, - })) - .sort((a, b) => a.userFqn.localeCompare(b.userFqn)); - + const sortedContracts = getSortedContractEntries(gasStatsByContract); const contracts: Record = {}; - for (const { userFqn, stats } of sortedContracts) { + for (const { userFqn, displayKey, stats } of sortedContracts) { const { sourceName, contractName } = parseFullyQualifiedName(userFqn); const deployment: DeploymentGasStatsJsonEntry | null = @@ -403,44 +426,34 @@ export class GasAnalyticsManagerImplementation implements GasAnalyticsManager { functions = Object.fromEntries(sortedFunctions); } - contracts[userFqn] = { sourceName, contractName, deployment, functions }; + contracts[displayKey] = { + sourceName, + contractName, + proxyChain: stats.proxyChain.map(getUserFqn), + deployment, + functions, + }; } return { contracts }; } } -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 "@/", where 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]; +function getSortedContractEntries( + gasStatsByContract: GasStatsByContract, +): Array<{ + userFqn: string; + displayKey: string; + proxyLabel: string | undefined; + stats: ContractGasStats; +}> { + 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)); } diff --git a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/helpers.ts b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/helpers.ts deleted file mode 100644 index 45d8ed549cc..00000000000 --- a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/helpers.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { GasAnalyticsManager } from "./types.js"; -import type { HookContext } from "../../../types/hooks.js"; -import type { HardhatRuntimeEnvironment } from "../../../types/hre.js"; - -import { assertHardhatInvariant } from "@nomicfoundation/hardhat-errors"; -import chalk from "chalk"; - -import { HardhatRuntimeEnvironmentImplementation } from "../../core/hre.js"; - -import { GasAnalyticsManagerImplementation } from "./gas-analytics-manager.js"; -import { - testRunDone, - testRunStart, - testWorkerDone, -} from "./hook-handlers/test.js"; - -export function getGasAnalyticsManager( - hookContextOrHre: HookContext | HardhatRuntimeEnvironment, -): GasAnalyticsManager { - assertHardhatInvariant( - "_gasAnalytics" in hookContextOrHre && - hookContextOrHre._gasAnalytics instanceof - GasAnalyticsManagerImplementation, - "Expected _gasAnalytics to be an instance of GasAnalyticsManagerImplementation", - ); - return hookContextOrHre._gasAnalytics; -} - -export function setGasAnalyticsManager( - hre: HardhatRuntimeEnvironment, - gasAnalyticsManager: GasAnalyticsManager, -): void { - assertHardhatInvariant( - hre instanceof HardhatRuntimeEnvironmentImplementation, - "Expected HRE to be an instance of HardhatRuntimeEnvironmentImplementation", - ); - hre._gasAnalytics = gasAnalyticsManager; -} - -/** - * The following helpers are kept for backward compatibility with older versions - * of test runner plugins (hardhat-mocha, hardhat-node-test-runner) that import - * from "hardhat/internal/gas-analytics". - */ - -// Dynamically import the HRE when calling the helpers -let cachedHre: HardhatRuntimeEnvironment | undefined; -async function getHre(): Promise { - if (cachedHre === undefined) { - const { default: hre } = await import("../../../index.js"); - cachedHre = hre; - } - return cachedHre; -} - -export async function markTestRunStart(id: string): Promise { - const hre = await getHre(); - await testRunStart(hre, id); -} - -export async function markTestWorkerDone(id: string): Promise { - const hre = await getHre(); - await testWorkerDone(hre, id); -} - -export async function markTestRunDone(id: string): Promise { - const hre = await getHre(); - await testRunDone(hre, id); -} - -export function formatSectionHeader( - sectionName: string, - { - changedLength, - addedLength, - removedLength, - }: { - changedLength: number; - addedLength: number; - removedLength: number; - }, -): string { - const parts: string[] = []; - - if (changedLength > 0) { - parts.push(`${changedLength} changed`); - } - if (addedLength > 0) { - parts.push(`${addedLength} added`); - } - if (removedLength > 0) { - parts.push(`${removedLength} removed`); - } - - return `${sectionName}: ${chalk.gray(parts.join(", "))}`; -} diff --git a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/helpers/accessors.ts b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/helpers/accessors.ts new file mode 100644 index 00000000000..d1d41c7a1d0 --- /dev/null +++ b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/helpers/accessors.ts @@ -0,0 +1,31 @@ +import type { HookContext } from "../../../../types/hooks.js"; +import type { HardhatRuntimeEnvironment } from "../../../../types/hre.js"; +import type { GasAnalyticsManager } from "../types.js"; + +import { assertHardhatInvariant } from "@nomicfoundation/hardhat-errors"; + +import { HardhatRuntimeEnvironmentImplementation } from "../../../core/hre.js"; +import { GasAnalyticsManagerImplementation } from "../gas-analytics-manager.js"; + +export function getGasAnalyticsManager( + hookContextOrHre: HookContext | HardhatRuntimeEnvironment, +): GasAnalyticsManager { + assertHardhatInvariant( + "_gasAnalytics" in hookContextOrHre && + hookContextOrHre._gasAnalytics instanceof + GasAnalyticsManagerImplementation, + "Expected _gasAnalytics to be an instance of GasAnalyticsManagerImplementation", + ); + return hookContextOrHre._gasAnalytics; +} + +export function setGasAnalyticsManager( + hre: HardhatRuntimeEnvironment, + gasAnalyticsManager: GasAnalyticsManager, +): void { + assertHardhatInvariant( + hre instanceof HardhatRuntimeEnvironmentImplementation, + "Expected HRE to be an instance of HardhatRuntimeEnvironmentImplementation", + ); + hre._gasAnalytics = gasAnalyticsManager; +} diff --git a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/helpers/compat.ts b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/helpers/compat.ts new file mode 100644 index 00000000000..26e2bb51112 --- /dev/null +++ b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/helpers/compat.ts @@ -0,0 +1,37 @@ +import type { HardhatRuntimeEnvironment } from "../../../../types/hre.js"; + +import { + testRunDone, + testRunStart, + testWorkerDone, +} from "../hook-handlers/test.js"; + +/** + * The following helpers are kept for backward compatibility with older versions + * of test runner plugins (hardhat-mocha, hardhat-node-test-runner) that import + * from "hardhat/internal/gas-analytics". + */ + +let cachedHre: HardhatRuntimeEnvironment | undefined; +async function getHre(): Promise { + if (cachedHre === undefined) { + const { default: hre } = await import("../../../../index.js"); + cachedHre = hre; + } + return cachedHre; +} + +export async function markTestRunStart(id: string): Promise { + const hre = await getHre(); + await testRunStart(hre, id); +} + +export async function markTestWorkerDone(id: string): Promise { + const hre = await getHre(); + await testWorkerDone(hre, id); +} + +export async function markTestRunDone(id: string): Promise { + const hre = await getHre(); + await testRunDone(hre, id); +} diff --git a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/helpers/utils.ts b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/helpers/utils.ts new file mode 100644 index 00000000000..c0db9bf9513 --- /dev/null +++ b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/helpers/utils.ts @@ -0,0 +1,112 @@ +import chalk from "chalk"; + +/** + * Converts an internal FQN (e.g. `"project/contracts/Foo.sol:Foo"` or + * `"npm/@oz/contracts@5.0.0/token/ERC20.sol:ERC20"`) to its user-friendly + * form by stripping the `project/` prefix or npm version segment. + */ +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 "@/", where may be scoped (@scope/pkg) + const match = withoutPrefix.match(/^(@?[^@/]+(?:\/[^@/]+)*)@[^/]+\/(.*)$/); + if (match !== null) { + return `${match[1]}/${match[2]}`; + } + return withoutPrefix; + } + + return inputFqn; +} + +/** + * Extracts the function name from a Solidity function signature + * (e.g. `"transfer(address,uint256)"` → `"transfer"`). + */ +export function getFunctionName(signature: string): string { + return signature.split("(")[0]; +} + +/** + * Builds a deterministic string key for grouping gas measurements by + * (contractFqn, proxyChain). Uses null-byte separators to avoid collisions. + */ +export function makeGroupKey( + contractFqn: string, + proxyChain: string[], +): string { + if (proxyChain.length === 0) { + return contractFqn; + } + return contractFqn + "\0" + proxyChain.join("\0"); +} + +/** + * Returns a human-readable proxy label like `"(via Proxy2 → Proxy)"`, + * or `undefined` for direct calls. Strips the last element (the + * implementation) and converts internal FQNs to user-friendly format. + */ +export function getProxyLabel(proxyChain: string[]): string | undefined { + const proxies = proxyChain.slice(0, -1).map(getUserFqn); + if (proxies.length === 0) { + return undefined; + } + return `(via ${proxies.join(" → ")})`; +} + +/** + * Returns a display key for a contract entry, appending the proxy label + * when the call went through a proxy chain. Used for table headers and + * JSON object keys. + */ +export function getDisplayKey(userFqn: string, proxyChain: string[]): string { + const label = getProxyLabel(proxyChain); + if (label === undefined) { + return userFqn; + } + return `${userFqn} ${label}`; +} + +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 formatSectionHeader( + sectionName: string, + { + changedLength, + addedLength, + removedLength, + }: { + changedLength: number; + addedLength: number; + removedLength: number; + }, +): string { + const parts: string[] = []; + + if (changedLength > 0) { + parts.push(`${changedLength} changed`); + } + if (addedLength > 0) { + parts.push(`${addedLength} added`); + } + if (removedLength > 0) { + parts.push(`${removedLength} removed`); + } + + return `${sectionName}: ${chalk.gray(parts.join(", "))}`; +} diff --git a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/hre.ts b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/hre.ts index b68e2e24feb..15b0e0921ad 100644 --- a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/hre.ts +++ b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/hre.ts @@ -1,7 +1,7 @@ import type { HardhatRuntimeEnvironmentHooks } from "../../../../types/hooks.js"; import { GasAnalyticsManagerImplementation } from "../gas-analytics-manager.js"; -import { setGasAnalyticsManager } from "../helpers.js"; +import { setGasAnalyticsManager } from "../helpers/accessors.js"; export default async (): Promise> => ({ created: async (context, hre) => { diff --git a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/test.ts b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/test.ts index c65f18a36d2..09fa7dac67d 100644 --- a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/test.ts +++ b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/hook-handlers/test.ts @@ -1,6 +1,6 @@ import type { HookContext, TestHooks } from "../../../../types/hooks.js"; -import { getGasAnalyticsManager } from "../helpers.js"; +import { getGasAnalyticsManager } from "../helpers/accessors.js"; export default async (): Promise> => ({ onTestRunStart: async (context, id, next) => { diff --git a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/snapshot-cheatcodes.ts b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/snapshot-cheatcodes.ts index faa99766133..39277c1c306 100644 --- a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/snapshot-cheatcodes.ts +++ b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/snapshot-cheatcodes.ts @@ -18,8 +18,7 @@ import { parseFullyQualifiedName, } from "../../../utils/contract-names.js"; -import { getUserFqn } from "./gas-analytics-manager.js"; -import { formatSectionHeader } from "./helpers.js"; +import { formatSectionHeader, getUserFqn } from "./helpers/utils.js"; export const SNAPSHOT_CHEATCODES_DIR = "snapshots"; diff --git a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/types.ts b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/types.ts index 003f7f5a455..e0da5eaa24d 100644 --- a/packages/hardhat/src/internal/builtin-plugins/gas-analytics/types.ts +++ b/packages/hardhat/src/internal/builtin-plugins/gas-analytics/types.ts @@ -25,6 +25,7 @@ export interface DeploymentGasStatsJsonEntry extends GasStatsJsonEntry { export interface ContractGasStatsJson { sourceName: string; contractName: string; + proxyChain: string[]; deployment: DeploymentGasStatsJsonEntry | null; functions: Record | null; } @@ -46,6 +47,7 @@ interface BaseGasMeasurement { interface FunctionGasMeasurement extends BaseGasMeasurement { type: "function"; functionSig: string; + proxyChain: string[]; } interface DeploymentGasMeasurement extends BaseGasMeasurement { diff --git a/packages/hardhat/src/internal/builtin-plugins/network-manager/edr/utils/convert-to-edr.ts b/packages/hardhat/src/internal/builtin-plugins/network-manager/edr/utils/convert-to-edr.ts index cafa173220d..c7d66c71c63 100644 --- a/packages/hardhat/src/internal/builtin-plugins/network-manager/edr/utils/convert-to-edr.ts +++ b/packages/hardhat/src/internal/builtin-plugins/network-manager/edr/utils/convert-to-edr.ts @@ -418,6 +418,7 @@ export function edrGasReportToHardhatGasMeasurements( type: "function", functionSig, gas: Number(call.gas), + proxyChain: call.proxyChain, }); } } diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index d3a4bcb0f31..3bc6b78a9bc 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -23,7 +23,7 @@ import { errorResult, successfulResult } from "../../../utils/result.js"; import { isSupportedChainType } from "../../edr/chain-type.js"; import { ArtifactManagerImplementation } from "../artifacts/artifact-manager.js"; import { getCoverageManager } from "../coverage/helpers.js"; -import { getGasAnalyticsManager } from "../gas-analytics/helpers.js"; +import { getGasAnalyticsManager } from "../gas-analytics/helpers/accessors.js"; import { edrGasReportToHardhatGasMeasurements } from "../network-manager/edr/utils/convert-to-edr.js"; import { diff --git a/packages/hardhat/src/internal/builtin-plugins/test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/test/task-action.ts index 6613e7781b1..87892452adc 100644 --- a/packages/hardhat/src/internal/builtin-plugins/test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/test/task-action.ts @@ -17,7 +17,7 @@ import { successfulResult, } from "../../../utils/result.js"; import { getCoverageManager } from "../coverage/helpers.js"; -import { getGasAnalyticsManager } from "../gas-analytics/helpers.js"; +import { getGasAnalyticsManager } from "../gas-analytics/helpers/accessors.js"; interface TestActionArguments { testFiles: string[]; diff --git a/packages/hardhat/test/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts b/packages/hardhat/test/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts index 5a4010bda6c..dd85f5ac27a 100644 --- a/packages/hardhat/test/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts +++ b/packages/hardhat/test/internal/builtin-plugins/gas-analytics/gas-analytics-manager.ts @@ -20,13 +20,16 @@ import { } from "@nomicfoundation/hardhat-utils/fs"; import chalk from "chalk"; +import { GasAnalyticsManagerImplementation } from "../../../../src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.js"; import { avg, - median, - getUserFqn, + getDisplayKey, getFunctionName, - GasAnalyticsManagerImplementation, -} from "../../../../src/internal/builtin-plugins/gas-analytics/gas-analytics-manager.js"; + getProxyLabel, + getUserFqn, + makeGroupKey, + median, +} from "../../../../src/internal/builtin-plugins/gas-analytics/helpers/utils.js"; import { getFullyQualifiedName } from "../../../../src/utils/contract-names.js"; describe("gas-analytics-manager", () => { @@ -53,6 +56,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }; manager.addGasMeasurement(functionMeasurement); @@ -85,6 +89,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }; const measurement2: GasMeasurement = { type: "deployment", @@ -110,6 +115,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }; const measurement2: GasMeasurement = { type: "deployment", @@ -149,6 +155,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }; const measurement2: GasMeasurement = { type: "deployment", @@ -174,6 +181,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }; const measurement2: GasMeasurement = { type: "deployment", @@ -212,6 +220,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }; const measurement2: GasMeasurement = { type: "deployment", @@ -239,12 +248,14 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }; const measurement2: GasMeasurement = { type: "function", contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "approve(address,uint256)", gas: 46000, + proxyChain: [], }; manager.addGasMeasurement(measurement1); @@ -269,6 +280,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }; const measurement2: GasMeasurement = { type: "deployment", @@ -307,6 +319,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); await manager.saveGasMeasurements("test-id"); @@ -324,6 +337,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); await manager.saveGasMeasurements("test-id"); @@ -353,6 +367,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); await manager.saveGasMeasurements("runner-1"); @@ -362,6 +377,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 35000, + proxyChain: [], }); await manager.saveGasMeasurements("runner-2"); @@ -393,12 +409,14 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 30000, + proxyChain: [], }); const result = manager._aggregateGasMeasurements(); @@ -461,12 +479,14 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "balanceOf(address)", gas: 15000, + proxyChain: [], }); const result = manager._aggregateGasMeasurements(); @@ -509,6 +529,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/TokenA.sol:TokenA", functionSig: "mint(uint256)", gas: 50000, + proxyChain: [], }); manager.addGasMeasurement({ type: "deployment", @@ -521,6 +542,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/TokenB.sol:TokenB", functionSig: "burn(uint256)", gas: 30000, + proxyChain: [], }); const result = manager._aggregateGasMeasurements(); @@ -570,18 +592,21 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 30000, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 20000, + proxyChain: [], }); const result = manager._aggregateGasMeasurements(); @@ -613,12 +638,14 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256,bytes)", gas: 35000, + proxyChain: [], }); const result = manager._aggregateGasMeasurements(); @@ -680,6 +707,80 @@ describe("gas-analytics-manager", () => { assert.deepEqual(contractMeasurements.deployments, [500000, 600000]); assert.equal(contractMeasurements.deploymentRuntimeSize, 2048); }); + + it("should group proxied function calls separately from direct calls", () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + const proxyChain = [ + "project/contracts/Proxies.sol:Proxy", + "project/contracts/Impl.sol:Impl", + ]; + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/Impl.sol:Impl", + functionSig: "foo()", + gas: 10000, + proxyChain: [], + }); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/Impl.sol:Impl", + functionSig: "foo()", + gas: 20000, + proxyChain, + }); + + const result = manager._aggregateGasMeasurements(); + + assert.equal(result.size, 2); + const directKey = makeGroupKey("project/contracts/Impl.sol:Impl", []); + const proxiedKey = makeGroupKey( + "project/contracts/Impl.sol:Impl", + proxyChain, + ); + const directMeasurements = result.get(directKey); + const proxiedMeasurements = result.get(proxiedKey); + assert.ok( + directMeasurements !== undefined, + "Direct measurements should be defined", + ); + assert.ok( + proxiedMeasurements !== undefined, + "Proxied measurements should be defined", + ); + assert.deepEqual(directMeasurements.functions.get("foo()"), [10000]); + assert.deepEqual(proxiedMeasurements.functions.get("foo()"), [20000]); + assert.deepEqual(directMeasurements.proxyChain, []); + assert.deepEqual(proxiedMeasurements.proxyChain, proxyChain); + }); + + it("should group different proxy chains separately", () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/Impl.sol:Impl", + functionSig: "foo()", + gas: 10000, + proxyChain: [ + "project/contracts/Proxies.sol:Proxy", + "project/contracts/Impl.sol:Impl", + ], + }); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/Impl.sol:Impl", + functionSig: "foo()", + gas: 30000, + proxyChain: [ + "project/contracts/Proxies.sol:Proxy2", + "project/contracts/Proxies.sol:Proxy", + "project/contracts/Impl.sol:Impl", + ], + }); + + const result = manager._aggregateGasMeasurements(); + + assert.equal(result.size, 2); + }); }); describe("_calculateGasStats", () => { @@ -690,18 +791,21 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 30000, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 20000, + proxyChain: [], }); const gasStats = manager._calculateGasStats(); @@ -778,12 +882,14 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/TokenA.sol:TokenA", functionSig: "mint(uint256)", gas: 50000, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/TokenB.sol:TokenB", functionSig: "burn(uint256)", gas: 30000, + proxyChain: [], }); const gasStats = manager._calculateGasStats(); @@ -814,12 +920,14 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256,bytes)", gas: 35000, + proxyChain: [], }); const gasStats = manager._calculateGasStats(); @@ -876,24 +984,28 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 33330, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 33334, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 33335, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 33340, + proxyChain: [], }); const gasStats = manager._calculateGasStats(); @@ -910,6 +1022,62 @@ describe("gas-analytics-manager", () => { assert.equal(transferStats.avg, 33335); assert.equal(transferStats.median, 33335); }); + + it("should duplicate deployment stats to proxied groups", () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + const proxyChain = [ + "project/contracts/Proxies.sol:Proxy", + "project/contracts/Impl.sol:Impl", + ]; + manager.addGasMeasurement({ + type: "deployment", + contractFqn: "project/contracts/Impl.sol:Impl", + gas: 500000, + runtimeSize: 2048, + }); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/Impl.sol:Impl", + functionSig: "foo()", + gas: 10000, + proxyChain: [], + }); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/Impl.sol:Impl", + functionSig: "foo()", + gas: 20000, + proxyChain, + }); + + const gasStats = manager._calculateGasStats(); + + const directKey = makeGroupKey("project/contracts/Impl.sol:Impl", []); + const proxiedKey = makeGroupKey( + "project/contracts/Impl.sol:Impl", + proxyChain, + ); + + const directStats = gasStats.get(directKey); + const proxiedStats = gasStats.get(proxiedKey); + assert.ok(directStats !== undefined, "Direct stats should be defined"); + assert.ok( + proxiedStats !== undefined, + "Proxied stats should be defined", + ); + + assert.ok( + directStats.deployment !== undefined, + "Direct deployment should be defined", + ); + assert.ok( + proxiedStats.deployment !== undefined, + "Proxied deployment should be defined", + ); + assert.equal(proxiedStats.deployment.min, 500000); + assert.equal(proxiedStats.deployment.runtimeSize, 2048); + assert.deepEqual(proxiedStats.proxyChain, proxyChain); + }); }); describe("_generateGasStatsReport", () => { @@ -926,6 +1094,7 @@ describe("gas-analytics-manager", () => { const gasStats = new Map(); // Contracts are added in non-alphabetical order to test sorting gasStats.set("project/contracts/TokenA.sol:TokenA", { + proxyChain: [], deployment: undefined, functions: new Map([ // Functions are added in non-alphabetical order to test sorting @@ -953,6 +1122,7 @@ describe("gas-analytics-manager", () => { }); gasStats.set("project/contracts/MyContract.sol:MyContract", { + proxyChain: [], deployment: { min: 400000, max: 600000, @@ -1024,6 +1194,7 @@ describe("gas-analytics-manager", () => { const manager = new GasAnalyticsManagerImplementation(tmpDir); const gasStats = new Map(); gasStats.set("project/contracts/TestContract.sol:TestContract", { + proxyChain: [], deployment: undefined, functions: new Map([ [ @@ -1069,6 +1240,7 @@ describe("gas-analytics-manager", () => { const manager = new GasAnalyticsManagerImplementation(tmpDir); const gasStats = new Map(); gasStats.set("project/contracts/TestContract.sol:TestContract", { + proxyChain: [], deployment: undefined, functions: new Map([ [ @@ -1131,6 +1303,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); const stats = manager._calculateGasStats(); const result = manager._generateGasStatsJson(stats); @@ -1165,6 +1338,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/Token.sol:Token", functionSig: "balanceOf(address)", gas: 15000, + proxyChain: [], }); const stats = manager._calculateGasStats(); const result = manager._generateGasStatsJson(stats); @@ -1224,12 +1398,14 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/Token.sol:Token", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/Token.sol:Token", functionSig: "approve(address,uint256)", gas: 46000, + proxyChain: [], }); const stats = manager._calculateGasStats(); const result = manager._generateGasStatsJson(stats); @@ -1252,12 +1428,14 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/Token.sol:Token", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); manager.addGasMeasurement({ type: "function", contractFqn: "project/contracts/Token.sol:Token", functionSig: "transfer(address,uint256,bytes)", gas: 35000, + proxyChain: [], }); const stats = manager._calculateGasStats(); const result = manager._generateGasStatsJson(stats); @@ -1308,6 +1486,7 @@ describe("gas-analytics-manager", () => { "npm/@openzeppelin/contracts@5.0.0/token/ERC20/ERC20.sol:ERC20", functionSig: "approve(address,uint256)", gas: 46200, + proxyChain: [], }); const stats = manager._calculateGasStats(); const result = manager._generateGasStatsJson(stats); @@ -1348,6 +1527,58 @@ describe("gas-analytics-manager", () => { assert.equal(contract.sourceName, sourceName); assert.equal(contract.contractName, contractName); }); + + it("should include proxyChain in JSON output and use display key", () => { + const manager = new GasAnalyticsManagerImplementation(tmpDir); + manager.addGasMeasurement({ + type: "deployment", + contractFqn: "project/contracts/Impl.sol:Impl", + gas: 500000, + runtimeSize: 2048, + }); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/Impl.sol:Impl", + functionSig: "foo()", + gas: 10000, + proxyChain: [], + }); + manager.addGasMeasurement({ + type: "function", + contractFqn: "project/contracts/Impl.sol:Impl", + functionSig: "foo()", + gas: 20000, + proxyChain: [ + "project/contracts/Proxies.sol:Proxy", + "project/contracts/Impl.sol:Impl", + ], + }); + + const stats = manager._calculateGasStats(); + const result = manager._generateGasStatsJson(stats); + + const directContract = result.contracts["contracts/Impl.sol:Impl"]; + assert.ok(directContract !== undefined, "direct entry should exist"); + assert.deepEqual(directContract.proxyChain, []); + assert.equal(directContract.sourceName, "contracts/Impl.sol"); + assert.equal(directContract.contractName, "Impl"); + + const proxiedContract = + result.contracts[ + "contracts/Impl.sol:Impl (via contracts/Proxies.sol:Proxy)" + ]; + assert.ok(proxiedContract !== undefined, "proxied entry should exist"); + assert.deepEqual(proxiedContract.proxyChain, [ + "contracts/Proxies.sol:Proxy", + "contracts/Impl.sol:Impl", + ]); + assert.equal(proxiedContract.sourceName, "contracts/Impl.sol"); + assert.equal(proxiedContract.contractName, "Impl"); + assert.ok( + proxiedContract.deployment !== null, + "proxied entry should have duplicated deployment stats", + ); + }); }); describe("writeGasStatsJson", () => { @@ -1371,6 +1602,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); await manager.saveGasMeasurements("test-id"); @@ -1433,6 +1665,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); await manager.saveGasMeasurements("test-id"); @@ -1459,6 +1692,7 @@ describe("gas-analytics-manager", () => { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "transfer(address,uint256)", gas: 25000, + proxyChain: [], }); await manager.saveGasMeasurements("test-id"); @@ -1534,6 +1768,115 @@ describe("gas-analytics-manager", () => { }); }); + describe("makeGroupKey", () => { + it("should return contractFqn for empty proxyChain", () => { + assert.equal( + makeGroupKey("project/contracts/A.sol:A", []), + "project/contracts/A.sol:A", + ); + }); + + it("should include proxyChain in key separated by null bytes", () => { + assert.equal( + makeGroupKey("project/contracts/A.sol:A", ["Proxy", "A"]), + "project/contracts/A.sol:A\0Proxy\0A", + ); + }); + + it("should produce different keys for different proxy chains", () => { + const key1 = makeGroupKey("project/contracts/A.sol:A", ["Proxy", "A"]); + const key2 = makeGroupKey("project/contracts/A.sol:A", [ + "Proxy2", + "Proxy", + "A", + ]); + assert.notEqual(key1, key2); + }); + }); + + describe("getDisplayKey", () => { + it("should return userFqn for empty proxyChain", () => { + assert.equal( + getDisplayKey("contracts/A.sol:A", []), + "contracts/A.sol:A", + ); + }); + + it("should strip implementation and format single proxy", () => { + assert.equal( + getDisplayKey("contracts/Impl.sol:Impl", [ + "project/contracts/Proxies.sol:Proxy", + "project/contracts/Impl.sol:Impl", + ]), + "contracts/Impl.sol:Impl (via contracts/Proxies.sol:Proxy)", + ); + }); + + it("should strip implementation and format multiple proxies", () => { + assert.equal( + getDisplayKey("contracts/Impl.sol:Impl", [ + "project/contracts/Proxies.sol:Proxy2", + "project/contracts/Proxies.sol:Proxy", + "project/contracts/Impl.sol:Impl", + ]), + "contracts/Impl.sol:Impl (via contracts/Proxies.sol:Proxy2 → contracts/Proxies.sol:Proxy)", + ); + }); + + it("should return userFqn when proxyChain has only the implementation", () => { + assert.equal( + getDisplayKey("contracts/Impl.sol:Impl", [ + "project/contracts/Impl.sol:Impl", + ]), + "contracts/Impl.sol:Impl", + ); + }); + }); + + describe("getProxyLabel", () => { + it("should return undefined for empty proxyChain", () => { + assert.equal(getProxyLabel([]), undefined); + }); + + it("should return undefined when only implementation in chain", () => { + assert.equal( + getProxyLabel(["project/contracts/Impl.sol:Impl"]), + undefined, + ); + }); + + it("should format single proxy and strip project/ prefix", () => { + assert.equal( + getProxyLabel([ + "project/contracts/Proxies.sol:Proxy", + "project/contracts/Impl.sol:Impl", + ]), + "(via contracts/Proxies.sol:Proxy)", + ); + }); + + it("should format multiple proxies and strip project/ prefix", () => { + assert.equal( + getProxyLabel([ + "project/contracts/Proxies.sol:Proxy2", + "project/contracts/Proxies.sol:Proxy", + "project/contracts/Impl.sol:Impl", + ]), + "(via contracts/Proxies.sol:Proxy2 → contracts/Proxies.sol:Proxy)", + ); + }); + + it("should strip npm package version from proxy names", () => { + assert.equal( + getProxyLabel([ + "npm/@openzeppelin/contracts@5.0.0/proxy/Proxy.sol:Proxy", + "project/contracts/Impl.sol:Impl", + ]), + "(via @openzeppelin/contracts/proxy/Proxy.sol:Proxy)", + ); + }); + }); + describe("getFunctionName", () => { it("should extract function name from signature", () => { assert.equal(getFunctionName("transfer(address,uint256)"), "transfer"); diff --git a/packages/hardhat/test/internal/builtin-plugins/test/task-action.ts b/packages/hardhat/test/internal/builtin-plugins/test/task-action.ts index da98640040d..41ab777763d 100644 --- a/packages/hardhat/test/internal/builtin-plugins/test/task-action.ts +++ b/packages/hardhat/test/internal/builtin-plugins/test/task-action.ts @@ -5,7 +5,7 @@ import { afterEach, describe, it } from "node:test"; import { overrideTask, task } from "../../../../src/config.js"; import { createHardhatRuntimeEnvironment } from "../../../../src/hre.js"; -import { getGasAnalyticsManager } from "../../../../src/internal/builtin-plugins/gas-analytics/helpers.js"; +import { getGasAnalyticsManager } from "../../../../src/internal/builtin-plugins/gas-analytics/helpers/accessors.js"; import { ArgumentType } from "../../../../src/types/arguments.js"; import { successfulResult, errorResult } from "../../../../src/utils/result.js"; @@ -331,6 +331,7 @@ describe("test/task-action", function () { contractFqn: "project/contracts/MyContract.sol:MyContract", functionSig: "staleFunctionFromRunnerB()", gas: 99999, + proxyChain: [], }); await gasAnalytics.saveGasMeasurements("runner-b");