Created
June 17, 2025 23:26
-
-
Save sambacha/ee292a3e0c81613908349f8f5e8f6c36 to your computer and use it in GitHub Desktop.
foundry gas snapshot parsing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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