Skip to content

Instantly share code, notes, and snippets.

@sambacha
Created June 17, 2025 23:26
Show Gist options
  • Save sambacha/ee292a3e0c81613908349f8f5e8f6c36 to your computer and use it in GitHub Desktop.
Save sambacha/ee292a3e0c81613908349f8f5e8f6c36 to your computer and use it in GitHub Desktop.
foundry gas snapshot parsing
#!/usr/bin/env node
// SPDX-License-Identifier: 0BSD
// BSD Zero Clause License
// @model: claude-sonnet-4-20250514
/**
* @fileoverview Foundry Gas Snapshot Parser
* @description Parses Foundry .gas-snapshot files into CSV format with detailed statistics
* @module gas-snapshot-parser
* @requires fs/promises
* @requires path
* @requires process
*/
import { readFile, writeFile, access, constants } from 'fs/promises';
import { resolve, dirname } from 'path';
import { exit, argv } from 'process';
import { fileURLToPath } from 'url';
// ES module compatibility for __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* @typedef {'standard' | 'fuzz' | 'invariant'} TestType
*
* @typedef {Object} BaseTestRecord
* @property {string} contract - Contract name
* @property {string} test - Test function name
* @property {TestType} testType - Type of test
*
* @typedef {BaseTestRecord & {testType: 'standard', gas: number}} StandardTestRecord
* @typedef {BaseTestRecord & {testType: 'fuzz', runs: number, mean: number, median: number}} FuzzTestRecord
* @typedef {BaseTestRecord & {testType: 'invariant', runs: number, calls: number, reverts: number}} InvariantTestRecord
*
* @typedef {StandardTestRecord | FuzzTestRecord | InvariantTestRecord} TestRecord
*
* @typedef {Object} ContractStats
* @property {string} contract - Contract name
* @property {TestRecord[]} tests - All tests for this contract
* @property {number} totalTests - Total number of tests
* @property {number} standardTests - Number of standard tests
* @property {number} fuzzTests - Number of fuzz tests
* @property {number} invariantTests - Number of invariant tests
* @property {number} totalGas - Total gas consumption
* @property {number} avgGas - Average gas consumption
* @property {Array<{test: string, gas: number, type: TestType}>} mostExpensiveTests - Top 5 most expensive tests
*
* @typedef {Object} Summary
* @property {number} totalTests - Total number of tests
* @property {number} standardTests - Number of standard tests
* @property {number} fuzzTests - Number of fuzz tests
* @property {number} invariantTests - Number of invariant tests
* @property {number} avgStandardGas - Average gas for standard tests
* @property {number} avgFuzzMean - Average mean gas for fuzz tests
*
* @typedef {Object} Statistics
* @property {string} generated - ISO timestamp of generation
* @property {string} inputFile - Absolute path to input file
* @property {Summary} summary - Summary statistics
* @property {ContractStats[]} byContract - Statistics grouped by contract
*/
// Regex patterns for parsing different test types
const PATTERNS = {
standard: /^(.+?):(.+?)\(\)\s*\(gas:\s*(\d+)\)$/,
fuzz: /^(.+?):(.+?)\(.*?\)\s*\(runs:\s*(\d+),\s*μ:\s*(\d+),\s*~:\s*(\d+)\)$/,
invariant: /^(.+?):(.+?)\(\)\s*\(runs:\s*(\d+),\s*calls:\s*(\d+),\s*reverts:\s*(\d+)\)$/
};
/**
* Parses command line arguments
* @returns {{inputFile: string, outputFile: string}} Parsed file paths
*/
const parseArguments = () => {
const [inputFile = '.gas-snapshot', outputFile = 'gas-snapshot.csv'] = argv.slice(2);
return { inputFile, outputFile };
};
/**
* Checks if a file exists and is readable
* @param {string} filePath - Path to check
* @returns {Promise<boolean>} True if file exists and is readable
*/
const fileExists = async (filePath) => {
try {
await access(filePath, constants.R_OK);
return true;
} catch {
return false;
}
};
/**
* Parses a single line from the gas snapshot file
* @param {string} line - Line to parse
* @param {number} lineNumber - Line number for error reporting
* @returns {TestRecord | null} Parsed test record or null if parsing failed
*/
const parseLine = (line, lineNumber) => {
const trimmedLine = line.trim();
if (!trimmedLine) return null;
// Try fuzz test pattern first (most specific)
const fuzzMatch = trimmedLine.match(PATTERNS.fuzz);
if (fuzzMatch) {
const [, contract, test, runs, mean, median] = fuzzMatch;
return {
contract,
test,
testType: 'fuzz',
runs: parseInt(runs, 10),
mean: parseInt(mean, 10),
median: parseInt(median, 10)
};
}
// Try invariant test pattern
const invariantMatch = trimmedLine.match(PATTERNS.invariant);
if (invariantMatch) {
const [, contract, test, runs, calls, reverts] = invariantMatch;
return {
contract,
test,
testType: 'invariant',
runs: parseInt(runs, 10),
calls: parseInt(calls, 10),
reverts: parseInt(reverts, 10)
};
}
// Try standard test pattern
const standardMatch = trimmedLine.match(PATTERNS.standard);
if (standardMatch) {
const [, contract, test, gas] = standardMatch;
return {
contract,
test,
testType: 'standard',
gas: parseInt(gas, 10)
};
}
console.warn(`⚠️ Warning: Could not parse line ${lineNumber}: ${trimmedLine}`);
return null;
};
/**
* Converts test records to CSV format
* @param {TestRecord[]} records - Test records to convert
* @returns {string} CSV formatted string
*/
const generateCSV = (records) => {
const headers = ['Contract', 'Test', 'Type', 'Gas', 'Runs', 'Mean', 'Median', 'Calls', 'Reverts'];
const rows = [headers.join(',')];
for (const record of records) {
const row = [
`"${record.contract}"`,
`"${record.test}"`,
`"${record.testType.charAt(0).toUpperCase() + record.testType.slice(1)}"`,
record.testType === 'standard' ? record.gas : '',
'runs' in record ? record.runs : '',
record.testType === 'fuzz' ? record.mean : '',
record.testType === 'fuzz' ? record.median : '',
record.testType === 'invariant' ? record.calls : '',
record.testType === 'invariant' ? record.reverts : ''
];
rows.push(row.join(','));
}
return rows.join('\n');
};
/**
* Calculates summary statistics from test records
* @param {TestRecord[]} records - Test records
* @returns {Summary} Summary statistics
*/
const calculateSummary = (records) => {
const byType = records.reduce((acc, record) => {
acc[record.testType] = (acc[record.testType] || 0) + 1;
return acc;
}, {});
const standardGasValues = records
.filter(r => r.testType === 'standard')
.map(r => r.gas);
const fuzzMeanValues = records
.filter(r => r.testType === 'fuzz')
.map(r => r.mean);
const avgStandardGas = standardGasValues.length > 0
? Math.round(standardGasValues.reduce((a, b) => a + b, 0) / standardGasValues.length)
: 0;
const avgFuzzMean = fuzzMeanValues.length > 0
? Math.round(fuzzMeanValues.reduce((a, b) => a + b, 0) / fuzzMeanValues.length)
: 0;
return {
totalTests: records.length,
standardTests: byType.standard || 0,
fuzzTests: byType.fuzz || 0,
invariantTests: byType.invariant || 0,
avgStandardGas,
avgFuzzMean
};
};
/**
* Groups test records by contract and calculates contract-level statistics
* @param {TestRecord[]} records - Test records
* @returns {Object<string, ContractStats>} Statistics grouped by contract name
*/
const groupByContract = (records) => {
const byContract = {};
for (const record of records) {
if (!byContract[record.contract]) {
byContract[record.contract] = {
contract: record.contract,
tests: [],
totalTests: 0,
standardTests: 0,
fuzzTests: 0,
invariantTests: 0,
totalGas: 0,
avgGas: 0
};
}
const contractStats = byContract[record.contract];
contractStats.tests.push(record);
contractStats.totalTests++;
contractStats[`${record.testType}Tests`]++;
if (record.testType === 'fuzz') {
contractStats.totalGas += record.mean;
} else if (record.testType === 'standard') {
contractStats.totalGas += record.gas;
}
}
// Calculate averages and find most expensive tests
for (const contractStats of Object.values(byContract)) {
const gasTests = contractStats.standardTests + contractStats.fuzzTests;
contractStats.avgGas = gasTests > 0
? Math.round(contractStats.totalGas / gasTests)
: 0;
// Sort tests by gas consumption (highest first)
contractStats.tests.sort((a, b) => {
const gasA = a.testType === 'fuzz' ? a.mean : (a.gas || 0);
const gasB = b.testType === 'fuzz' ? b.mean : (b.gas || 0);
return gasB - gasA;
});
// Keep only top 5 most expensive tests
contractStats.mostExpensiveTests = contractStats.tests
.slice(0, 5)
.map(t => ({
test: t.test,
gas: t.testType === 'fuzz' ? t.mean : (t.gas || 0),
type: t.testType
}));
// Remove full test list to keep memory usage low
delete contractStats.tests;
}
return byContract;
};
/**
* Finds outlier tests based on gas consumption using IQR method
* @param {TestRecord[]} records - Test records
* @param {number} maxOutliers - Maximum number of outliers to return
* @returns {Array<{contract: string, test: string, gas: number}>} Outlier tests
*/
const findOutliers = (records, maxOutliers = 10) => {
const gasValues = records
.filter(r => r.testType === 'standard')
.map(r => ({ ...r, gasValue: r.gas }));
if (gasValues.length === 0) return [];
const sortedGas = [...gasValues].sort((a, b) => a.gasValue - b.gasValue);
const q1Index = Math.floor(sortedGas.length * 0.25);
const q3Index = Math.floor(sortedGas.length * 0.75);
const q1 = sortedGas[q1Index].gasValue;
const q3 = sortedGas[q3Index].gasValue;
const iqr = q3 - q1;
const upperBound = q3 + 1.5 * iqr;
return gasValues
.filter(r => r.gasValue > upperBound)
.sort((a, b) => b.gasValue - a.gasValue)
.slice(0, maxOutliers)
.map(({ contract, test, gasValue }) => ({
contract,
test,
gas: gasValue
}));
};
/**
* Prints summary information to console
* @param {string} inputFile - Input file path
* @param {string} outputFile - Output file path
* @param {Summary} summary - Summary statistics
* @param {Array<{contract: string, test: string, gas: number}>} outliers - Outlier tests
*/
const printSummary = (inputFile, outputFile, summary, outliers) => {
console.log(`\n✅ Successfully parsed ${inputFile} to ${outputFile}`);
console.log(`\n📊 Summary:`);
console.log(` Total tests: ${summary.totalTests}`);
console.log(` Standard tests: ${summary.standardTests}`);
console.log(` Fuzz tests: ${summary.fuzzTests}`);
console.log(` Invariant tests: ${summary.invariantTests}`);
if (summary.standardTests > 0) {
console.log(` Average gas (standard tests): ${summary.avgStandardGas.toLocaleString()}`);
}
if (summary.fuzzTests > 0) {
console.log(` Average mean gas (fuzz tests): ${summary.avgFuzzMean.toLocaleString()}`);
}
if (outliers.length > 0) {
console.log(`\n⚠️ Gas consumption outliers (top ${outliers.length}):`);
for (const { contract, test, gas } of outliers) {
console.log(` ${contract}:${test} - ${gas.toLocaleString()} gas`);
}
}
};
/**
* Main function to parse gas snapshot file
* @async
* @returns {Promise<void>}
*/
const main = async () => {
try {
// Parse arguments
const { inputFile, outputFile } = parseArguments();
// Check if input file exists
if (!(await fileExists(inputFile))) {
console.error(`❌ Error: Input file '${inputFile}' not found`);
exit(1);
}
// Read and parse file
const content = await readFile(inputFile, 'utf8');
const lines = content.trim().split('\n');
// Parse lines into records
const records = lines
.map((line, index) => parseLine(line, index + 1))
.filter(Boolean);
if (records.length === 0) {
console.error('❌ Error: No valid test records found in input file');
exit(1);
}
// Generate and write CSV
const csv = generateCSV(records);
await writeFile(outputFile, csv);
// Calculate statistics
const summary = calculateSummary(records);
const byContract = groupByContract(records);
const outliers = findOutliers(records);
// Generate detailed statistics file
const statsFile = outputFile.replace('.csv', '-stats.json');
const statistics = {
generated: new Date().toISOString(),
inputFile: resolve(inputFile),
summary,
byContract: Object.values(byContract).sort((a, b) => b.avgGas - a.avgGas)
};
await writeFile(statsFile, JSON.stringify(statistics, null, 2));
console.log(`\n📈 Detailed statistics saved to: ${statsFile}`);
// Print summary
printSummary(inputFile, outputFile, summary, outliers);
} catch (error) {
console.error(`\n❌ Error: ${error.message}`);
exit(1);
}
};
// Run the script
main();
#!/usr/bin/env node
// SPDX-License-Identifier: 0BSD
// BSD Zero Clause License
/**
* Script to parse Foundry .gas-snapshot files into CSV format
*
* Usage:
* node parse-gas-snapshot.js [input-file] [output-file]
*
* If no arguments provided:
* - input: .gas-snapshot (default)
* - output: gas-snapshot.csv (default)
*/
const fs = require('fs');
const path = require('path');
// Parse command line arguments
const args = process.argv.slice(2);
const inputFile = args[0] || '.gas-snapshot';
const outputFile = args[1] || 'gas-snapshot.csv';
// Check if input file exists
if (!fs.existsSync(inputFile)) {
console.error(`Error: Input file '${inputFile}' not found`);
process.exit(1);
}
// Read the gas snapshot file
const content = fs.readFileSync(inputFile, 'utf8');
const lines = content.trim().split('\n');
// Parse each line
const records = [];
lines.forEach((line, index) => {
// Skip empty lines
if (!line.trim()) return;
// Parse the line format: ContractName:testName() (gas: value) or with runs info
const basicMatch = line.match(/^(.+?):(.+?)\(\)\s*\(gas:\s*(\d+)\)$/);
const fuzzMatch = line.match(/^(.+?):(.+?)\(.*?\)\s*\(runs:\s*(\d+),\s*μ:\s*(\d+),\s*~:\s*(\d+)\)$/);
const invariantMatch = line.match(/^(.+?):(.+?)\(\)\s*\(runs:\s*(\d+),\s*calls:\s*(\d+),\s*reverts:\s*(\d+)\)$/);
if (fuzzMatch) {
// Fuzz test with statistics
records.push({
contract: fuzzMatch[1],
test: fuzzMatch[2],
runs: parseInt(fuzzMatch[3]),
mean: parseInt(fuzzMatch[4]),
median: parseInt(fuzzMatch[5]),
isFuzzTest: true,
testType: 'fuzz'
});
} else if (invariantMatch) {
// Invariant test
records.push({
contract: invariantMatch[1],
test: invariantMatch[2],
runs: parseInt(invariantMatch[3]),
calls: parseInt(invariantMatch[4]),
reverts: parseInt(invariantMatch[5]),
isInvariantTest: true,
testType: 'invariant'
});
} else if (basicMatch) {
// Basic test with single gas value
records.push({
contract: basicMatch[1],
test: basicMatch[2],
gas: parseInt(basicMatch[3]),
isFuzzTest: false,
testType: 'standard'
});
} else {
console.warn(`Warning: Could not parse line ${index + 1}: ${line}`);
}
});
// Generate CSV
const csvLines = ['Contract,Test,Type,Gas,Runs,Mean,Median,Calls,Reverts'];
records.forEach(record => {
if (record.testType === 'fuzz') {
csvLines.push(`"${record.contract}","${record.test}","Fuzz",,${record.runs},${record.mean},${record.median},,`);
} else if (record.testType === 'invariant') {
csvLines.push(`"${record.contract}","${record.test}","Invariant",,${record.runs},,,${record.calls},${record.reverts}`);
} else {
csvLines.push(`"${record.contract}","${record.test}","Standard",${record.gas},,,,,`);
}
});
// Write CSV file
fs.writeFileSync(outputFile, csvLines.join('\n'));
// Generate summary statistics
const totalTests = records.length;
const fuzzTests = records.filter(r => r.testType === 'fuzz').length;
const invariantTests = records.filter(r => r.testType === 'invariant').length;
const standardTests = records.filter(r => r.testType === 'standard').length;
const standardGasValues = records
.filter(r => r.testType === 'standard')
.map(r => r.gas);
const avgStandardGas = standardGasValues.length > 0
? Math.round(standardGasValues.reduce((a, b) => a + b, 0) / standardGasValues.length)
: 0;
const fuzzMeanValues = records
.filter(r => r.testType === 'fuzz')
.map(r => r.mean);
const avgFuzzMean = fuzzMeanValues.length > 0
? Math.round(fuzzMeanValues.reduce((a, b) => a + b, 0) / fuzzMeanValues.length)
: 0;
// Print summary
console.log(`\n✅ Successfully parsed ${inputFile} to ${outputFile}`);
console.log(`\n📊 Summary:`);
console.log(` Total tests: ${totalTests}`);
console.log(` Standard tests: ${standardTests}`);
console.log(` Fuzz tests: ${fuzzTests}`);
console.log(` Invariant tests: ${invariantTests}`);
if (standardTests > 0) {
console.log(` Average gas (standard tests): ${avgStandardGas.toLocaleString()}`);
}
if (fuzzTests > 0) {
console.log(` Average mean gas (fuzz tests): ${avgFuzzMean.toLocaleString()}`);
}
// Optional: Generate detailed statistics file
const generateDetailedStats = true;
if (generateDetailedStats) {
const statsFile = outputFile.replace('.csv', '-stats.json');
// Group by contract
const byContract = {};
records.forEach(record => {
if (!byContract[record.contract]) {
byContract[record.contract] = {
contract: record.contract,
tests: [],
totalTests: 0,
fuzzTests: 0,
standardTests: 0,
totalGas: 0,
avgGas: 0
};
}
byContract[record.contract].tests.push(record);
byContract[record.contract].totalTests++;
if (record.testType === 'fuzz') {
byContract[record.contract].fuzzTests++;
byContract[record.contract].totalGas += record.mean;
} else if (record.testType === 'standard') {
byContract[record.contract].standardTests++;
byContract[record.contract].totalGas += record.gas;
} else if (record.testType === 'invariant') {
if (!byContract[record.contract].invariantTests) {
byContract[record.contract].invariantTests = 0;
}
byContract[record.contract].invariantTests++;
}
});
// Calculate averages
Object.values(byContract).forEach(contract => {
contract.avgGas = Math.round(contract.totalGas / contract.totalTests);
// Sort tests by gas consumption (highest first)
contract.tests.sort((a, b) => {
const gasA = a.testType === 'fuzz' ? a.mean : (a.gas || 0);
const gasB = b.testType === 'fuzz' ? b.mean : (b.gas || 0);
return gasB - gasA;
});
// Keep only top 5 most expensive tests
contract.mostExpensiveTests = contract.tests.slice(0, 5).map(t => ({
test: t.test,
gas: t.testType === 'fuzz' ? t.mean : (t.gas || 0),
type: t.testType
}));
delete contract.tests; // Remove full test list to keep JSON compact
});
const stats = {
generated: new Date().toISOString(),
inputFile: path.resolve(inputFile),
summary: {
totalTests,
standardTests,
fuzzTests,
invariantTests,
avgStandardGas,
avgFuzzMean
},
byContract: Object.values(byContract).sort((a, b) => b.avgGas - a.avgGas)
};
fs.writeFileSync(statsFile, JSON.stringify(stats, null, 2));
console.log(`\n📈 Detailed statistics saved to: ${statsFile}`);
}
// Optional: Find outliers (tests with unusually high gas)
const findOutliers = true;
if (findOutliers && standardGasValues.length > 0) {
const sortedGas = [...standardGasValues].sort((a, b) => a - b);
const q1 = sortedGas[Math.floor(sortedGas.length * 0.25)];
const q3 = sortedGas[Math.floor(sortedGas.length * 0.75)];
const iqr = q3 - q1;
const upperBound = q3 + 1.5 * iqr;
const outliers = records
.filter(r => r.testType === 'standard' && r.gas > upperBound)
.sort((a, b) => b.gas - a.gas)
.slice(0, 10); // Top 10 outliers
if (outliers.length > 0) {
console.log(`\n⚠️ Gas consumption outliers (top ${outliers.length}):`);
outliers.forEach(test => {
console.log(` ${test.contract}:${test.test} - ${test.gas.toLocaleString()} gas`);
});
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment