Skip to content

Commit bc5adba

Browse files
gonzaotcarr00
andauthored
Basic hardhat gas reporter config (#137)
Co-authored-by: Arr00 <[email protected]>
1 parent 3f9b00d commit bc5adba

File tree

5 files changed

+327
-1
lines changed

5 files changed

+327
-1
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: Compare gas costs
2+
description: Compare gas costs between branches
3+
inputs:
4+
token:
5+
description: GitHub token, required to access GitHub API
6+
required: true
7+
report:
8+
description: Path to the report to compare
9+
required: false
10+
default: gasReporterOutput.json
11+
out_report:
12+
description: Path to save the output report
13+
required: false
14+
default: ${{ github.ref_name }}.gasreport.json
15+
ref_report:
16+
description: Path to the reference report for comparison
17+
required: false
18+
default: ${{ github.base_ref }}.gasreport.json
19+
20+
runs:
21+
using: composite
22+
steps:
23+
- name: Download reference report
24+
if: github.event_name == 'pull_request'
25+
run: |
26+
RUN_ID=`gh run list --repo ${{ github.repository }} --branch ${{ github.base_ref }} --workflow ${{ github.workflow }} --limit 100 --json 'conclusion,databaseId,event' --jq 'map(select(.conclusion=="success" and .event!="pull_request"))[0].databaseId'`
27+
gh run download ${RUN_ID} --repo ${{ github.repository }} -n gasreport
28+
env:
29+
GITHUB_TOKEN: ${{ inputs.token }}
30+
shell: bash
31+
continue-on-error: true
32+
id: reference
33+
- name: Compare reports
34+
if: steps.reference.outcome == 'success' && github.event_name == 'pull_request'
35+
run: |
36+
node scripts/checks/compare-gas-reports.js ${{ inputs.report }} ${{ inputs.ref_report }} >> $GITHUB_STEP_SUMMARY
37+
env:
38+
STYLE: markdown
39+
shell: bash
40+
- name: Rename report for upload
41+
if: github.event_name != 'pull_request'
42+
run: |
43+
mv ${{ inputs.report }} ${{ inputs.out_report }}
44+
shell: bash
45+
- name: Save report
46+
if: github.event_name != 'pull_request'
47+
uses: actions/upload-artifact@v4
48+
with:
49+
name: gasreport
50+
overwrite: true
51+
path: ${{ inputs.out_report }}

.github/workflows/checks.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ jobs:
4747
run: npm run test:pragma
4848
- name: Check procedurally generated contracts are up-to-date
4949
run: npm run test:generation
50+
- name: Compare gas costs
51+
uses: ./.github/actions/gas-compare
52+
with:
53+
token: ${{ github.token }}
5054

5155
coverage:
5256
runs-on: ubuntu-latest

hardhat.config.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
// - COMPILER: compiler version (default: 0.8.27)
2+
// - HARDFORK: hardfork version (default: prague)
3+
// - GAS: enable gas report (default: false)
4+
// - COINMARKETCAP: coinmarketcap api key for USD value in gas report
5+
16
const { argv } = require('yargs/yargs')()
27
.env('')
38
.options({
@@ -9,11 +14,22 @@ const { argv } = require('yargs/yargs')()
914
type: 'string',
1015
default: 'prague',
1116
},
17+
gas: {
18+
alias: 'enableGasReport',
19+
type: 'boolean',
20+
default: false,
21+
},
22+
coinmarketcap: {
23+
alias: 'coinmarketcap',
24+
type: 'string',
25+
default: '',
26+
},
1227
});
1328

1429
require('@nomicfoundation/hardhat-chai-matchers');
1530
require('@nomicfoundation/hardhat-ethers');
1631
require('hardhat-exposed');
32+
require('hardhat-gas-reporter');
1733
require('solidity-coverage');
1834
require('solidity-docgen');
1935
require('./hardhat/remappings');
@@ -35,5 +51,12 @@ module.exports = {
3551
hardfork: argv.hardfork,
3652
},
3753
},
54+
gasReporter: {
55+
enabled: argv.gas,
56+
showMethodSig: true,
57+
includeBytecodeInJSON: true,
58+
currency: 'USD',
59+
coinmarketcap: argv.coinmarketcap,
60+
},
3861
docgen: require('./docs/config'),
3962
};

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"test": "hardhat test",
2727
"test:generation": "scripts/checks/generation.sh",
2828
"test:inheritance": "scripts/checks/inheritance-ordering.js artifacts/build-info/*",
29-
"test:pragma": "scripts/checks/pragma-consistency.js artifacts/build-info/*"
29+
"test:pragma": "scripts/checks/pragma-consistency.js artifacts/build-info/*",
30+
"gas-report": "env ENABLE_GAS_REPORT=true npm run test"
3031
},
3132
"homepage": "https://openzeppelin.com/contracts/",
3233
"repository": {

scripts/checks/compare-gas-reports.js

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const chalk = require('chalk');
5+
const { argv } = require('yargs')
6+
.env()
7+
.options({
8+
style: {
9+
type: 'string',
10+
choices: ['shell', 'markdown'],
11+
default: 'shell',
12+
},
13+
hideEqual: {
14+
type: 'boolean',
15+
default: true,
16+
},
17+
strictTesting: {
18+
type: 'boolean',
19+
default: false,
20+
},
21+
});
22+
23+
// Deduce base tx cost from the percentage denominator
24+
const BASE_TX_COST = 21000;
25+
26+
// Utilities
27+
function sum(...args) {
28+
return args.reduce((a, b) => a + b, 0);
29+
}
30+
31+
function average(...args) {
32+
return sum(...args) / args.length;
33+
}
34+
35+
function variation(current, previous, offset = 0) {
36+
return {
37+
value: current,
38+
delta: current - previous,
39+
prcnt: (100 * (current - previous)) / (previous - offset),
40+
};
41+
}
42+
43+
// Report class
44+
class Report {
45+
// Read report file
46+
static load(filepath) {
47+
return JSON.parse(fs.readFileSync(filepath, 'utf8'));
48+
}
49+
50+
// Compare two reports
51+
static compare(update, ref, opts = { hideEqual: true, strictTesting: false }) {
52+
if (JSON.stringify(update.options?.solcInfo) !== JSON.stringify(ref.options?.solcInfo)) {
53+
console.warn('WARNING: Reports produced with non matching metadata');
54+
}
55+
56+
// gasReporter 1.0.0 uses ".info", but 2.0.0 uses ".data"
57+
const updateInfo = update.info ?? update.data;
58+
const refInfo = ref.info ?? ref.data;
59+
60+
const deployments = updateInfo.deployments
61+
.map(contract =>
62+
Object.assign(contract, { previousVersion: refInfo.deployments.find(({ name }) => name === contract.name) }),
63+
)
64+
.filter(contract => contract.gasData?.length && contract.previousVersion?.gasData?.length)
65+
.flatMap(contract => [
66+
{
67+
contract: contract.name,
68+
method: '[bytecode length]',
69+
avg: variation(contract.bytecode.length / 2 - 1, contract.previousVersion.bytecode.length / 2 - 1),
70+
},
71+
{
72+
contract: contract.name,
73+
method: '[construction cost]',
74+
avg: variation(
75+
...[contract.gasData, contract.previousVersion.gasData].map(x => Math.round(average(...x))),
76+
BASE_TX_COST,
77+
),
78+
},
79+
])
80+
.sort((a, b) => `${a.contract}:${a.method}`.localeCompare(`${b.contract}:${b.method}`));
81+
82+
const methods = Object.keys(updateInfo.methods)
83+
.filter(key => refInfo.methods[key])
84+
.filter(key => updateInfo.methods[key].numberOfCalls > 0)
85+
.filter(
86+
key => !opts.strictTesting || updateInfo.methods[key].numberOfCalls === refInfo.methods[key].numberOfCalls,
87+
)
88+
.map(key => ({
89+
contract: refInfo.methods[key].contract,
90+
method: refInfo.methods[key].fnSig,
91+
min: variation(...[updateInfo, refInfo].map(x => Math.min(...x.methods[key].gasData)), BASE_TX_COST),
92+
max: variation(...[updateInfo, refInfo].map(x => Math.max(...x.methods[key].gasData)), BASE_TX_COST),
93+
avg: variation(...[updateInfo, refInfo].map(x => Math.round(average(...x.methods[key].gasData))), BASE_TX_COST),
94+
}))
95+
.sort((a, b) => `${a.contract}:${a.method}`.localeCompare(`${b.contract}:${b.method}`));
96+
97+
return []
98+
.concat(deployments, methods)
99+
.filter(row => !opts.hideEqual || row.min?.delta || row.max?.delta || row.avg?.delta);
100+
}
101+
}
102+
103+
// Display
104+
function center(text, length) {
105+
return text.padStart((text.length + length) / 2).padEnd(length);
106+
}
107+
108+
function plusSign(num) {
109+
return num > 0 ? '+' : '';
110+
}
111+
112+
function formatCellShell(cell) {
113+
const format = chalk[cell?.delta > 0 ? 'red' : cell?.delta < 0 ? 'green' : 'reset'];
114+
return [
115+
format((!isFinite(cell?.value) ? '-' : cell.value.toString()).padStart(8)),
116+
format((!isFinite(cell?.delta) ? '-' : plusSign(cell.delta) + cell.delta.toString()).padStart(8)),
117+
format((!isFinite(cell?.prcnt) ? '-' : plusSign(cell.prcnt) + cell.prcnt.toFixed(2) + '%').padStart(8)),
118+
];
119+
}
120+
121+
function formatCmpShell(rows) {
122+
const contractLength = Math.max(8, ...rows.map(({ contract }) => contract.length));
123+
const methodLength = Math.max(7, ...rows.map(({ method }) => method.length));
124+
125+
const COLS = [
126+
{ txt: '', length: 0 },
127+
{ txt: 'Contract', length: contractLength },
128+
{ txt: 'Method', length: methodLength },
129+
{ txt: 'Min', length: 30 },
130+
{ txt: 'Max', length: 30 },
131+
{ txt: 'Avg', length: 30 },
132+
{ txt: '', length: 0 },
133+
];
134+
const HEADER = COLS.map(entry => chalk.bold(center(entry.txt, entry.length || 0)))
135+
.join(' | ')
136+
.trim();
137+
const SEPARATOR = COLS.map(({ length }) => (length > 0 ? '-'.repeat(length + 2) : ''))
138+
.join('|')
139+
.trim();
140+
141+
return [
142+
'',
143+
HEADER,
144+
...rows.map(entry =>
145+
[
146+
'',
147+
chalk.grey(entry.contract.padEnd(contractLength)),
148+
entry.method.padEnd(methodLength),
149+
...formatCellShell(entry.min),
150+
...formatCellShell(entry.max),
151+
...formatCellShell(entry.avg),
152+
'',
153+
]
154+
.join(' | ')
155+
.trim(),
156+
),
157+
'',
158+
]
159+
.join(`\n${SEPARATOR}\n`)
160+
.trim();
161+
}
162+
163+
function alignPattern(align) {
164+
switch (align) {
165+
case 'left':
166+
case undefined:
167+
return ':-';
168+
case 'right':
169+
return '-:';
170+
case 'center':
171+
return ':-:';
172+
}
173+
}
174+
175+
function trend(value) {
176+
return value > 0 ? ':x:' : value < 0 ? ':heavy_check_mark:' : ':heavy_minus_sign:';
177+
}
178+
179+
function formatCellMarkdown(cell) {
180+
return [
181+
!isFinite(cell?.value) ? '-' : cell.value.toString(),
182+
!isFinite(cell?.delta) ? '-' : plusSign(cell.delta) + cell.delta.toString(),
183+
!isFinite(cell?.prcnt) ? '-' : plusSign(cell.prcnt) + cell.prcnt.toFixed(2) + '% ' + trend(cell.delta),
184+
];
185+
}
186+
187+
function formatCmpMarkdown(rows) {
188+
const COLS = [
189+
{ txt: '' },
190+
{ txt: 'Contract', align: 'left' },
191+
{ txt: 'Method', align: 'left' },
192+
{ txt: 'Min', align: 'right' },
193+
{ txt: '(+/-)', align: 'right' },
194+
{ txt: '%', align: 'right' },
195+
{ txt: 'Max', align: 'right' },
196+
{ txt: '(+/-)', align: 'right' },
197+
{ txt: '%', align: 'right' },
198+
{ txt: 'Avg', align: 'right' },
199+
{ txt: '(+/-)', align: 'right' },
200+
{ txt: '%', align: 'right' },
201+
{ txt: '' },
202+
];
203+
const HEADER = COLS.map(entry => entry.txt)
204+
.join(' | ')
205+
.trim();
206+
const SEPARATOR = COLS.map(entry => (entry.txt ? alignPattern(entry.align) : ''))
207+
.join('|')
208+
.trim();
209+
210+
return [
211+
'# Changes to gas costs',
212+
'',
213+
HEADER,
214+
SEPARATOR,
215+
rows
216+
.map(entry =>
217+
[
218+
'',
219+
entry.contract,
220+
entry.method,
221+
...formatCellMarkdown(entry.min),
222+
...formatCellMarkdown(entry.max),
223+
...formatCellMarkdown(entry.avg),
224+
'',
225+
]
226+
.join(' | ')
227+
.trim(),
228+
)
229+
.join('\n'),
230+
'',
231+
]
232+
.join('\n')
233+
.trim();
234+
}
235+
236+
// MAIN
237+
const report = Report.compare(Report.load(argv._[0]), Report.load(argv._[1]), argv);
238+
239+
switch (argv.style) {
240+
case 'markdown':
241+
console.log(formatCmpMarkdown(report));
242+
break;
243+
case 'shell':
244+
default:
245+
console.log(formatCmpShell(report));
246+
break;
247+
}

0 commit comments

Comments
 (0)