From 9388499606c143d7b4a30bd3c813424af49a5b08 Mon Sep 17 00:00:00 2001 From: Vladislav Lapin <51929896+loglapa@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:59:59 +0400 Subject: [PATCH] feat: add contract artifact compatibility check --- .../cmds/contracts/check_artifacts.test.ts | 58 ++++++++ .../cli/src/cmds/contracts/check_artifacts.ts | 139 ++++++++++++++++++ yarn-project/cli/src/cmds/contracts/index.ts | 10 ++ 3 files changed, 207 insertions(+) create mode 100644 yarn-project/cli/src/cmds/contracts/check_artifacts.test.ts create mode 100644 yarn-project/cli/src/cmds/contracts/check_artifacts.ts diff --git a/yarn-project/cli/src/cmds/contracts/check_artifacts.test.ts b/yarn-project/cli/src/cmds/contracts/check_artifacts.test.ts new file mode 100644 index 000000000000..96f3457a7842 --- /dev/null +++ b/yarn-project/cli/src/cmds/contracts/check_artifacts.test.ts @@ -0,0 +1,58 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { inspectArtifacts } from './check_artifacts.js'; + +describe('check artifacts', () => { + it('finds incompatible verification keys and reports their source', async () => { + const directory = await mkdtemp(join(tmpdir(), 'aztec-check-artifacts-')); + const nested = join(directory, 'nested'); + await mkdir(nested); + await writeArtifact(join(directory, 'pool.json'), 'pool', [ + ['deposit', 32], + ['withdraw', 16], + ]); + await writeArtifact(join(nested, 'treasury.json'), 'treasury', [['deposit', 16]]); + + const result = await inspectArtifacts([directory], 32); + + expect(result.records).toHaveLength(3); + expect(result.incompatible).toEqual([ + expect.objectContaining({ contractName: 'treasury', functionName: 'deposit', size: 16 }), + expect.objectContaining({ contractName: 'pool', functionName: 'withdraw', size: 16 }), + ]); + expect([...result.sizes.keys()]).toEqual([16, 32]); + }); + + it('detects mixed sizes without guessing which group is stale', async () => { + const directory = await mkdtemp(join(tmpdir(), 'aztec-check-artifacts-')); + await writeArtifact(join(directory, 'contract.json'), 'contract', [ + ['current', 32], + ['unknown', 16], + ]); + + const result = await inspectArtifacts([directory]); + + expect(result.expectedSize).toBeUndefined(); + expect(result.incompatible).toEqual([]); + expect([...result.sizes.entries()].map(([size, records]) => [size, records.length])).toEqual([ + [32, 1], + [16, 1], + ]); + }); +}); + +async function writeArtifact(path: string, name: string, functions: [string, number][]) { + await writeFile( + path, + JSON.stringify({ + name, + functions: functions.map(([functionName, size]) => ({ + name: functionName, + custom_attributes: ['private'], + verification_key: Buffer.alloc(size).toString('base64'), + })), + }), + ); +} diff --git a/yarn-project/cli/src/cmds/contracts/check_artifacts.ts b/yarn-project/cli/src/cmds/contracts/check_artifacts.ts new file mode 100644 index 000000000000..4c7302856bdd --- /dev/null +++ b/yarn-project/cli/src/cmds/contracts/check_artifacts.ts @@ -0,0 +1,139 @@ +import { MEGA_APP_VK_LENGTH_IN_FIELDS } from '@aztec/constants'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import type { LogFn } from '@aztec/foundation/log'; + +import { readFile, readdir, stat } from 'node:fs/promises'; +import { extname, join, resolve } from 'node:path'; + +type VerificationKeyRecord = { + artifactPath: string; + contractName: string; + functionName: string; + size: number; +}; + +type ArtifactCheck = { + records: VerificationKeyRecord[]; + expectedSize?: number; + incompatible: VerificationKeyRecord[]; + sizes: Map; +}; + +const INSTALLED_VK_SIZE = MEGA_APP_VK_LENGTH_IN_FIELDS * Fr.SIZE_IN_BYTES; + +/** Scans compiled contract artifacts and throws when their verification keys are incompatible. */ +export async function checkArtifacts(paths: string[], expected: 'installed' | undefined, log: LogFn): Promise { + const result = await inspectArtifacts(paths, expected === 'installed' ? INSTALLED_VK_SIZE : undefined); + if (result.records.length === 0) { + log('No private-function verification keys found.'); + return; + } + + if (result.expectedSize !== undefined && result.incompatible.length > 0) { + log(`FAIL: incompatible verification key size for the installed Aztec toolchain.`); + log( + ` Expected ${result.expectedSize} bytes; ${result.incompatible.length} of ${result.records.length} keys differ.`, + ); + for (const record of result.incompatible) { + log(` ${record.contractName}::${record.functionName} — ${record.size} bytes (${record.artifactPath})`); + } + log(' Rebuild all contract artifacts with the pinned toolchain.'); + throw new Error('Incompatible contract artifacts detected'); + } + + if (result.sizes.size > 1) { + log('FAIL: mixed verification key sizes detected; consistency alone cannot identify which artifacts are stale.'); + for (const [size, records] of result.sizes) { + log(` ${size} bytes: ${records.length} key(s)`); + for (const record of records) { + log(` ${record.contractName}::${record.functionName} (${record.artifactPath})`); + } + } + log(' Rebuild all contract artifacts with the pinned toolchain.'); + throw new Error('Mixed contract artifact verification key sizes detected'); + } + + log(`OK: checked ${result.records.length} private-function verification key(s).`); +} + +/** Reads artifact JSON and groups private-function verification keys by their serialized byte size. */ +export async function inspectArtifacts(paths: string[], expectedSize?: number): Promise { + const files = await collectJsonFiles(paths); + const records = (await Promise.all(files.map(readArtifactVerificationKeys))).flat(); + const sizes = new Map(); + for (const record of records) { + sizes.set(record.size, [...(sizes.get(record.size) ?? []), record]); + } + return { + records, + expectedSize, + incompatible: expectedSize === undefined ? [] : records.filter(record => record.size !== expectedSize), + sizes, + }; +} + +async function collectJsonFiles(paths: string[]): Promise { + const files: string[] = []; + for (const input of paths) { + const path = resolve(input); + const info = await stat(path); + if (info.isFile()) { + if (extname(path) === '.json') { + files.push(path); + } + continue; + } + if (!info.isDirectory()) { + continue; + } + const entries = await readdir(path, { withFileTypes: true }); + files.push(...(await collectJsonFiles(entries.map(entry => join(path, entry.name))))); + } + return files.sort(); +} + +async function readArtifactVerificationKeys(artifactPath: string): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(artifactPath, 'utf8')); + } catch (error) { + throw new Error(`Failed to parse JSON file ${artifactPath}`, { cause: error }); + } + if (!isRecord(parsed) || typeof parsed.name !== 'string' || !Array.isArray(parsed.functions)) { + return []; + } + + const records: VerificationKeyRecord[] = []; + for (const fn of parsed.functions) { + if (!isRecord(fn) || typeof fn.name !== 'string') { + continue; + } + const verificationKey = + typeof fn.verification_key === 'string' + ? fn.verification_key + : typeof fn.verificationKey === 'string' + ? fn.verificationKey + : undefined; + if (verificationKey === undefined) { + continue; + } + records.push({ + artifactPath, + contractName: parsed.name, + functionName: fn.name, + size: decodeVerificationKey(verificationKey).length, + }); + } + return records; +} + +function decodeVerificationKey(value: string): Buffer { + if (/^0x[0-9a-f]+$/i.test(value)) { + return Buffer.from(value.slice(2), 'hex'); + } + return Buffer.from(value, 'base64'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/yarn-project/cli/src/cmds/contracts/index.ts b/yarn-project/cli/src/cmds/contracts/index.ts index 70bba7c83846..2c756b09a8a0 100644 --- a/yarn-project/cli/src/cmds/contracts/index.ts +++ b/yarn-project/cli/src/cmds/contracts/index.ts @@ -3,6 +3,16 @@ import type { LogFn, Logger } from '@aztec/foundation/log'; import type { Command } from 'commander'; export function injectCommands(program: Command, log: LogFn, debugLogger: Logger) { + program + .command('check-artifacts') + .description('Checks compiled contract artifacts for incompatible private-function verification keys') + .argument('', 'One or more artifact directories or JSON files') + .option('--consistency-only', 'Only report mixed verification-key sizes without checking the installed toolchain') + .action(async (artifactDirectories: string[], options: { consistencyOnly?: boolean }) => { + const { checkArtifacts } = await import('./check_artifacts.js'); + await checkArtifacts(artifactDirectories, options.consistencyOnly ? undefined : 'installed', log); + }); + program .command('inspect-contract') .description('Shows list of external callable functions for a contract')