Skip to content

Instantly share code, notes, and snippets.

@mverzilli
Last active January 6, 2026 17:58
Show Gist options
  • Select an option

  • Save mverzilli/737c2e2ffbbd4438b2202b1fddabc5d6 to your computer and use it in GitHub Desktop.

Select an option

Save mverzilli/737c2e2ffbbd4438b2202b1fddabc5d6 to your computer and use it in GitHub Desktop.
#!/usr/bin/env npx tsx
/**
* Script to analyze and compare bytecode sizes of contract functions.
*
* Usage:
* # Show bytecode sizes for current build
* npx tsx scripts/compare-bytecode-sizes.ts
*
* # Output JSON for later comparison
* npx tsx scripts/compare-bytecode-sizes.ts --json > sizes.json
*
* # Compare two JSON files
* npx tsx scripts/compare-bytecode-sizes.ts --diff before.json after.json
*/
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, '..');
interface NoirFunctionArtifact {
name: string;
is_unconstrained: boolean;
custom_attributes?: string[];
bytecode: string; // base64 encoded
}
interface NoirContractArtifact {
name: string;
functions: NoirFunctionArtifact[];
}
interface FunctionSize {
name: string;
type: 'private' | 'utility' | 'public' | 'unknown';
bytecodeBytes: number;
bytecodeFields: number;
}
interface ContractSizes {
name: string;
functions: FunctionSize[];
totalBytes: number;
privateBytes: number;
utilityBytes: number;
}
interface BytecodeSizes {
commit: string;
timestamp: string;
contracts: ContractSizes[];
}
function getArtifactPaths(): string[] {
const artifactLocations = [
// TestContract - used for function broadcasts in e2e_multiple_blobs test
join(ROOT, 'noir-test-contracts.js/artifacts/test_contract-Test.json'),
// AvmTestContract - used for contract class publication in the test
join(ROOT, 'noir-test-contracts.js/artifacts/avm_test_contract-AvmTest.json'),
];
return artifactLocations.filter(p => existsSync(p));
}
function loadArtifact(path: string): NoirContractArtifact | null {
try {
const content = readFileSync(path, 'utf-8');
return JSON.parse(content) as NoirContractArtifact;
} catch {
return null;
}
}
function getFunctionType(fn: NoirFunctionArtifact): 'private' | 'utility' | 'public' | 'unknown' {
const attrs = fn.custom_attributes || [];
if (attrs.includes('abi_private') || attrs.includes('private')) {
return 'private';
}
if (attrs.includes('abi_utility') || attrs.includes('utility')) {
return 'utility';
}
if (attrs.includes('abi_public') || attrs.includes('public')) {
// Public functions that are unconstrained are utility functions in the TS sense
return fn.is_unconstrained ? 'utility' : 'public';
}
if (fn.is_unconstrained) {
return 'utility';
}
return 'unknown';
}
function calculateContractSizes(artifact: NoirContractArtifact): ContractSizes {
const functions: FunctionSize[] = [];
let totalBytes = 0;
let privateBytes = 0;
let utilityBytes = 0;
for (const fn of artifact.functions) {
if (!fn.bytecode) continue;
const bytecodeBuffer = Buffer.from(fn.bytecode, 'base64');
const bytecodeBytes = bytecodeBuffer.length;
const bytecodeFields = Math.ceil(bytecodeBytes / 31);
const type = getFunctionType(fn);
functions.push({
name: fn.name,
type,
bytecodeBytes,
bytecodeFields,
});
totalBytes += bytecodeBytes;
if (type === 'private') privateBytes += bytecodeBytes;
if (type === 'utility') utilityBytes += bytecodeBytes;
}
// Sort by type then by size (descending)
functions.sort((a, b) => {
if (a.type !== b.type) {
const typeOrder = { private: 0, utility: 1, public: 2, unknown: 3 };
return typeOrder[a.type] - typeOrder[b.type];
}
return b.bytecodeBytes - a.bytecodeBytes;
});
return {
name: artifact.name,
functions,
totalBytes,
privateBytes,
utilityBytes,
};
}
function getCurrentCommit(): string {
try {
return execSync('git rev-parse --short HEAD', { cwd: ROOT, encoding: 'utf-8' }).trim();
} catch {
return 'unknown';
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function printSizes(sizes: BytecodeSizes): void {
console.log(`\nBytecode sizes at commit: ${sizes.commit}`);
console.log(`Timestamp: ${sizes.timestamp}\n`);
for (const contract of sizes.contracts) {
console.log(`\n${'='.repeat(60)}`);
console.log(` ${contract.name}`);
console.log(`${'='.repeat(60)}\n`);
// Print by type
for (const type of ['private', 'utility', 'public'] as const) {
const fns = contract.functions.filter(f => f.type === type);
if (fns.length === 0) continue;
console.log(`\n${type.toUpperCase()} FUNCTIONS (${fns.length}):\n`);
console.log(' Name Bytes Fields');
console.log(' ' + '-'.repeat(65));
for (const fn of fns) {
const name = fn.name.length > 42 ? fn.name.substring(0, 39) + '...' : fn.name;
console.log(` ${name.padEnd(42)} ${formatSize(fn.bytecodeBytes).padStart(10)} ${fn.bytecodeFields.toString().padStart(10)}`);
}
}
console.log('\n SUMMARY:');
console.log(` Total: ${formatSize(contract.totalBytes)}`);
console.log(` Private: ${formatSize(contract.privateBytes)}`);
console.log(` Utility: ${formatSize(contract.utilityBytes)}`);
}
}
function compareSizes(before: BytecodeSizes, after: BytecodeSizes): void {
console.log(`\n${'='.repeat(70)}`);
console.log(` BYTECODE SIZE COMPARISON`);
console.log(`${'='.repeat(70)}`);
console.log(`\n Before: ${before.commit} (${before.timestamp})`);
console.log(` After: ${after.commit} (${after.timestamp})\n`);
for (const afterContract of after.contracts) {
const beforeContract = before.contracts.find(c => c.name === afterContract.name);
console.log(`\n${'='.repeat(60)}`);
console.log(` ${afterContract.name}`);
console.log(`${'='.repeat(60)}`);
if (!beforeContract) {
console.log('\n (New contract - no comparison available)\n');
continue;
}
// Compare private functions (most relevant for the blob size issue)
const beforePrivate = beforeContract.functions.filter(f => f.type === 'private');
const afterPrivate = afterContract.functions.filter(f => f.type === 'private');
console.log('\n PRIVATE FUNCTIONS:\n');
console.log(' Name Before After Diff');
console.log(' ' + '-'.repeat(75));
for (const afterFn of afterPrivate) {
const beforeFn = beforePrivate.find(f => f.name === afterFn.name);
const name = afterFn.name.length > 36 ? afterFn.name.substring(0, 33) + '...' : afterFn.name;
if (beforeFn) {
const diff = afterFn.bytecodeBytes - beforeFn.bytecodeBytes;
const pct = beforeFn.bytecodeBytes > 0 ? ((diff / beforeFn.bytecodeBytes) * 100).toFixed(0) : 'N/A';
const diffStr = diff >= 0 ? `+${formatSize(diff)}` : `-${formatSize(-diff)}`;
console.log(` ${name.padEnd(36)} ${formatSize(beforeFn.bytecodeBytes).padStart(10)} ${formatSize(afterFn.bytecodeBytes).padStart(10)} ${diffStr.padStart(12)} (${pct}%)`);
} else {
console.log(` ${name.padEnd(36)} ${'N/A'.padStart(10)} ${formatSize(afterFn.bytecodeBytes).padStart(10)} ${'NEW'.padStart(12)}`);
}
}
// Summary
const privateDiff = afterContract.privateBytes - beforeContract.privateBytes;
const privatePct = beforeContract.privateBytes > 0
? ((privateDiff / beforeContract.privateBytes) * 100).toFixed(1)
: 'N/A';
console.log('\n SUMMARY:');
console.log(` Private functions: ${formatSize(beforeContract.privateBytes)} -> ${formatSize(afterContract.privateBytes)}`);
console.log(` Change: ${privateDiff >= 0 ? '+' : ''}${formatSize(Math.abs(privateDiff))} (${privatePct}%)`);
// Calculate average private function size
const avgBefore = beforePrivate.length > 0 ? beforeContract.privateBytes / beforePrivate.length : 0;
const avgAfter = afterPrivate.length > 0 ? afterContract.privateBytes / afterPrivate.length : 0;
console.log(` Avg private fn size: ${formatSize(avgBefore)} -> ${formatSize(avgAfter)}`);
}
}
function collectCurrentSizes(): BytecodeSizes {
const sizes: BytecodeSizes = {
commit: getCurrentCommit(),
timestamp: new Date().toISOString(),
contracts: [],
};
const paths = getArtifactPaths();
if (paths.length === 0) {
console.error('No contract artifacts found. Make sure contracts are built.');
console.error('Run: yarn workspace @aztec/noir-test-contracts.js build');
process.exit(1);
}
for (const path of paths) {
const artifact = loadArtifact(path);
if (artifact) {
sizes.contracts.push(calculateContractSizes(artifact));
}
}
return sizes;
}
function loadSizesFromFile(path: string): BytecodeSizes {
const content = readFileSync(path, 'utf-8');
return JSON.parse(content) as BytecodeSizes;
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Bytecode Size Analysis Tool
===========================
Analyzes and compares bytecode sizes of contract functions, useful for
understanding the impact of code changes on contract deployment costs.
Usage:
npx tsx scripts/compare-bytecode-sizes.ts [options]
Options:
--json Output sizes as JSON (pipe to file for comparison)
--diff <before> <after> Compare two JSON files
--help, -h Show this help message
Examples:
# Show current bytecode sizes in human-readable format
npx tsx scripts/compare-bytecode-sizes.ts
# Save current sizes to JSON file
npx tsx scripts/compare-bytecode-sizes.ts --json > after.json
# Compare two saved snapshots
npx tsx scripts/compare-bytecode-sizes.ts --diff before.json after.json
Workflow for comparing commits:
1. Checkout the "before" commit
2. Build contracts: yarn workspace @aztec/noir-test-contracts.js build
3. Save sizes: npx tsx scripts/compare-bytecode-sizes.ts --json > before.json
4. Checkout the "after" commit
5. Build contracts: yarn workspace @aztec/noir-test-contracts.js build
6. Compare: npx tsx scripts/compare-bytecode-sizes.ts --diff before.json after.json
`);
return;
}
if (args.includes('--json')) {
const sizes = collectCurrentSizes();
console.log(JSON.stringify(sizes, null, 2));
return;
}
if (args.includes('--diff')) {
const diffIdx = args.indexOf('--diff');
const beforePath = args[diffIdx + 1];
const afterPath = args[diffIdx + 2];
if (!beforePath || !afterPath) {
console.error('Usage: --diff <before.json> <after.json>');
process.exit(1);
}
const before = loadSizesFromFile(beforePath);
const after = loadSizesFromFile(afterPath);
compareSizes(before, after);
return;
}
// Default: show current sizes
const sizes = collectCurrentSizes();
printSizes(sizes);
}
main().catch(console.error);
# From Noir Code to L1 Blob: The Data Journey
This Claude generated document traces how Noir contract code ends up in Ethereum L1 blobs (to be reviewed).
## Overview Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ COMPILE TIME │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Noir Source │ │ Nargo Compiler │ │ Artifact │ │
│ │ │ ───► │ │ ───► │ (.json) │ │
│ │ - Private fns │ │ - ACIR (constr) │ │ │ │
│ │ - Utility fns │ │ - Brillig (unc) │ │ bytecode: b64 │ │
│ │ - Note types │ │ │ │ (per function) │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
│ Example: sync_private_state (unconstrained) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Noir source (~1,600 lines across discovery modules) │ │
│ │ ↓ │ │
│ │ Brillig bytecode: 34.4 KB (base64 encoded in artifact) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ TRANSACTION TIME │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ broadcastPrivateFunction() creates a transaction: │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Bytecode Buffer Field Packing │ │
│ │ ───────────────── ───────────── │ │
│ │ │ │
│ │ [byte][byte][byte]... Each Fr field ≈ 31 bytes │ │
│ │ │ │ │ │
│ │ └──────────────────┬────────────────┘ │ │
│ │ ▼ │ │
│ │ bytecode.length / 31 = num_fields │ │
│ │ │ │
│ │ Example: call_create_note │ │
│ │ 56,315 bytes ÷ 31 = 1,817 fields │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ Transaction contains: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ • Capsule with bytecode fields (sent to ContractClassRegistry) │ │
│ │ • Function metadata (selector, vk_hash, etc.) │ │
│ │ • Merkle proofs for artifact tree │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ L2 BLOCK │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Sequencer collects transactions into a block: │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Block Body (txEffects) │ │
│ │ ├── TX 1: publishContractClass(AvmTest) │ │
│ │ │ └── contractClassLogs: [public_dispatch] (1,931 fields) │ │
│ │ ├── TX 2: broadcastPrivateFunction[0] │ │
│ │ │ └── contractClassLogs: [bytecode...] (224 fields) │ │
│ │ ├── TX 3: broadcastPrivateFunction[1] │ │
│ │ │ └── contractClassLogs: [bytecode...] (210 fields) │ │
│ │ ├── TX 4: broadcastPrivateFunction[2] │ │
│ │ │ └── contractClassLogs: [bytecode...] (1,807 fields) │ │
│ │ ├── TX 5: broadcastPrivateFunction[3] │ │
│ │ │ └── contractClassLogs: [bytecode...] (1,817 fields) │ │
│ │ ├── TX 6: broadcastPrivateFunction[4] │ │
│ │ │ └── contractClassLogs: [bytecode...] (1,798 fields) │ │
│ │ ├── TX 7: broadcastUtilityFunction[0] │ │
│ │ │ └── contractClassLogs: [bytecode...] (130 fields) │ │
│ │ └── TX 8: BatchCall (note, nullifier, logs, etc.) │ │
│ │ └── noteHashes, nullifiers, privateLogs, publicLogs... │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ block.getCheckpointBlobFields() serializes all data: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ • Block header │ │
│ │ • All txEffects (noteHashes, nullifiers, logs, contractClassLogs) │ │
│ │ • Total: ~8,000 fields │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ L1 BLOBS (EIP-4844) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ FIELDS_PER_BLOB = 4,096 fields │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Block fields: ~8,067 │ │
│ │ │ │ │
│ │ ├──► Blob 1: fields[0..4095] (4,096 fields) │ │
│ │ └──► Blob 2: fields[4096..8066] (3,971 fields + padding) │ │
│ │ │ │
│ │ numBlobs = ceil(numFields / FIELDS_PER_BLOB) │ │
│ │ = ceil(8067 / 4096) = 2 blobs │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Each blob is posted to Ethereum L1 via EIP-4844 blob transaction │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## What Gets Published Where
**Important distinction:**
- `publishContractClass()` → Only publishes `public_dispatch` bytecode (the AVM entrypoint)
- `broadcastPrivateFunction()` → Publishes individual private function bytecode
- `broadcastUtilityFunction()` → Publishes individual utility function bytecode
### Bytecode Sizes (Current - After Removing Inline Discovery)
| TX | Type | Function | Bytecode | Fields |
|----|------|----------|----------|--------|
| 1 | Contract Class | AvmTest `public_dispatch` only | 58.4 KB | 1,931 |
| 2 | Broadcast | assert_header_private | 6.8 KB | 224 |
| 3 | Broadcast | assert_private_global_vars | 6.4 KB | 210 |
| 4 | Broadcast | call_create_and_complete_partial_note | 54.7 KB | 1,807 |
| 5 | Broadcast | call_create_note | 55.0 KB | 1,817 |
| 6 | Broadcast | call_create_partial_note | 54.4 KB | 1,798 |
| 7 | Broadcast | call_view_notes (utility) | 3.9 KB | 130 |
| 8 | BatchCall | Various side effects | - | ~150 |
| | **Total** | | | **~8,067** |
## What Goes Into Blob Fields?
The `block.getCheckpointBlobFields()` encodes:
1. **Block Header** - timestamps, block number, state roots
2. **Per-TX Effects:**
- `noteHashes` - 32 bytes each
- `nullifiers` - 32 bytes each
- `l2ToL1Msgs` - message content
- `publicDataWrites` - storage updates
- `privateLogs` - encrypted note/event logs
- `publicLogs` - public event data
- `contractClassLogs` - **bytecode** (the big one!)
Contract class logs (bytecode broadcasts) typically dominate blob usage because bytecode is much larger than individual state changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment