From 022a15ad68e1bbab08d8e9208dab7c386b754487 Mon Sep 17 00:00:00 2001 From: dbanks12 Date: Wed, 13 May 2026 17:26:15 +0000 Subject: [PATCH] feat(canonical-contracts): autogen Noir interface stubs from compiled artifacts --- .../src/authwit/auth_registry_interface.nr | 21 +- .../aztec/src/public_checks_interface.nr | 35 ++- yarn-project/standard-contracts/package.json | 1 + .../src/scripts/generate_interfaces.ts | 51 +++ .../generate_interfaces.test.ts | 48 +++ .../generate_interfaces.ts | 297 ++++++++++++++++++ 6 files changed, 428 insertions(+), 25 deletions(-) create mode 100644 yarn-project/standard-contracts/src/scripts/generate_interfaces.ts create mode 100644 yarn-project/standard-contracts/src/standard-interfaces/generate_interfaces.test.ts create mode 100644 yarn-project/standard-contracts/src/standard-interfaces/generate_interfaces.ts diff --git a/noir-projects/aztec-nr/aztec/src/authwit/auth_registry_interface.nr b/noir-projects/aztec-nr/aztec/src/authwit/auth_registry_interface.nr index 5174af825e84..468a614656a7 100644 --- a/noir-projects/aztec-nr/aztec/src/authwit/auth_registry_interface.nr +++ b/noir-projects/aztec-nr/aztec/src/authwit/auth_registry_interface.nr @@ -1,15 +1,18 @@ +// GENERATED FILE - DO NOT EDIT +// +// Written by `yarn-project/standard-contracts/src/scripts/generate_interfaces.ts` from the compiled +// `AuthRegistry` artifact. Regenerate with +// `yarn workspace @aztec/standard-contracts run regen:standard-interfaces`. +// +// The selectors below are derived via `comptime { FunctionSelector::from_signature(...) }` at +// Noir compile time, with the signature string emitted from the artifact's parameter list. This +// keeps the wrapper in lockstep with whatever the `#[aztec]` macro generates for the real +// contract; any drift between the contract's external signatures and this file fails the +// `generate_interfaces.test.ts` freshness gate. + use crate::context::calls::{PublicCall, PublicStaticCall}; use crate::protocol::{abis::function_selector::FunctionSelector, address::AztecAddress, traits::ToField}; -/// Hand-written interface stub for the `AuthRegistry` canonical contract. -/// -/// The `AuthRegistry` contract records public authentication witnesses -/// (`set_authorized`, `set_reject_all`) and atomically consumes them at -/// the consumer (`consume`). Consumer code in aztec-nr's `authwit::public` -/// module calls into the registry via this interface. -/// -/// The selectors are derived with `comptime { FunctionSelector::from_signature(...) }`, -/// which matches exactly what the `#[aztec]` macro generates for the real contract. pub struct AuthRegistryInterface { pub target_contract: AztecAddress, } diff --git a/noir-projects/aztec-nr/aztec/src/public_checks_interface.nr b/noir-projects/aztec-nr/aztec/src/public_checks_interface.nr index c3902b01f511..f11b0b5deafd 100644 --- a/noir-projects/aztec-nr/aztec/src/public_checks_interface.nr +++ b/noir-projects/aztec-nr/aztec/src/public_checks_interface.nr @@ -1,15 +1,18 @@ +// GENERATED FILE - DO NOT EDIT +// +// Written by `yarn-project/standard-contracts/src/scripts/generate_interfaces.ts` from the compiled +// `PublicChecks` artifact. Regenerate with +// `yarn workspace @aztec/standard-contracts run regen:standard-interfaces`. +// +// The selectors below are derived via `comptime { FunctionSelector::from_signature(...) }` at +// Noir compile time, with the signature string emitted from the artifact's parameter list. This +// keeps the wrapper in lockstep with whatever the `#[aztec]` macro generates for the real +// contract; any drift between the contract's external signatures and this file fails the +// `generate_interfaces.test.ts` freshness gate. + use crate::context::calls::PublicStaticCall; -use crate::protocol::abis::function_selector::FunctionSelector; -use crate::protocol::address::AztecAddress; +use crate::protocol::{abis::function_selector::FunctionSelector, address::AztecAddress}; -/// Hand-written interface stub for the `PublicChecks` standard contract. -/// -/// The `PublicChecks` contract exposes two view functions that can be enqueued -/// from private context via `enqueue_view_incognito` to assert timestamp or -/// block-number constraints without revealing the calling contract address. -/// -/// The selectors are derived with `comptime { FunctionSelector::from_signature(...) }`, -/// which matches exactly what the `#[aztec]` macro generates for the real contract. pub struct PublicChecksInterface { pub target_contract: AztecAddress, } @@ -19,22 +22,22 @@ impl PublicChecksInterface { Self { target_contract } } - pub fn check_timestamp(self, operation: u8, value: u64) -> PublicStaticCall<15, 2, ()> { - let selector = comptime { FunctionSelector::from_signature("check_timestamp(u8,u64)") }; + pub fn check_block_number(self, operation: u8, value: u32) -> PublicStaticCall<18, 2, ()> { + let selector = comptime { FunctionSelector::from_signature("check_block_number(u8,u32)") }; PublicStaticCall::new( self.target_contract, selector, - "check_timestamp", + "check_block_number", [operation as Field, value as Field], ) } - pub fn check_block_number(self, operation: u8, value: u32) -> PublicStaticCall<18, 2, ()> { - let selector = comptime { FunctionSelector::from_signature("check_block_number(u8,u32)") }; + pub fn check_timestamp(self, operation: u8, value: u64) -> PublicStaticCall<15, 2, ()> { + let selector = comptime { FunctionSelector::from_signature("check_timestamp(u8,u64)") }; PublicStaticCall::new( self.target_contract, selector, - "check_block_number", + "check_timestamp", [operation as Field, value as Field], ) } diff --git a/yarn-project/standard-contracts/package.json b/yarn-project/standard-contracts/package.json index a2139adf2879..cf7908a36ba9 100644 --- a/yarn-project/standard-contracts/package.json +++ b/yarn-project/standard-contracts/package.json @@ -26,6 +26,7 @@ "generate": "yarn generate:data", "generate:cleanup-artifacts": "node --no-warnings --loader @swc-node/register/esm src/scripts/cleanup_artifacts.ts", "generate:data": "node --no-warnings --loader @swc-node/register/esm src/scripts/generate_data.ts", + "regen:standard-interfaces": "node --no-warnings --loader @swc-node/register/esm src/scripts/generate_interfaces.ts", "build:dev": "../scripts/tsc.sh --watch", "build:ts": "../scripts/tsc.sh", "clean": "rm -rf ./dest .tsbuildinfo ./artifacts", diff --git a/yarn-project/standard-contracts/src/scripts/generate_interfaces.ts b/yarn-project/standard-contracts/src/scripts/generate_interfaces.ts new file mode 100644 index 000000000000..9672571d8d6b --- /dev/null +++ b/yarn-project/standard-contracts/src/scripts/generate_interfaces.ts @@ -0,0 +1,51 @@ +// CLI entry for regenerating the autogenerated Noir interface stubs that wrap the standard +// `AuthRegistry` and `PublicChecks` contracts' external functions. +// +// Reads the compiled artifacts (produced by phase 1 of +// `noir-projects/noir-contracts/bootstrap.sh`), renders each `.gen.nr` deterministically from +// the artifact's parameter list, and writes the result back. The freshness test in +// `../standard-interfaces/generate_interfaces.test.ts` re-runs this renderer and asserts +// byte-equality against what is committed. +import type { NoirCompiledContract } from '@aztec/stdlib/noir'; + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { + ALL_INTERFACES, + type InterfaceSpec, + renderInterfaceFile, +} from '../standard-interfaces/generate_interfaces.js'; + +async function writeIfChanged(filePath: string, content: string): Promise { + try { + const existing = await fs.readFile(filePath, 'utf8'); + if (existing === content) { + return; + } + } catch (err: any) { + if (err.code !== 'ENOENT') { + throw err; + } + } + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); +} + +async function regenerateInterface(spec: InterfaceSpec): Promise { + const artifactRaw = await fs.readFile(spec.artifactPath, 'utf8'); + const artifact = JSON.parse(artifactRaw) as NoirCompiledContract; + const content = renderInterfaceFile(spec, artifact); + await writeIfChanged(spec.outputPath, content); + process.stdout.write(`regenerated ${spec.interfaceName} -> ${spec.outputPath}\n`); +} + +async function main() { + for (const spec of ALL_INTERFACES) { + await regenerateInterface(spec); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await main(); +} diff --git a/yarn-project/standard-contracts/src/standard-interfaces/generate_interfaces.test.ts b/yarn-project/standard-contracts/src/standard-interfaces/generate_interfaces.test.ts new file mode 100644 index 000000000000..958b41ceeea8 --- /dev/null +++ b/yarn-project/standard-contracts/src/standard-interfaces/generate_interfaces.test.ts @@ -0,0 +1,48 @@ +import type { NoirCompiledContract } from '@aztec/stdlib/noir'; + +import { promises as fs } from 'node:fs'; + +import { ALL_INTERFACES, renderInterfaceFile } from './generate_interfaces.js'; + +const REGEN_HINT = + 'standard interface stubs are stale; run `yarn workspace @aztec/standard-contracts run regen:standard-interfaces` and commit the result.'; + +describe('standard interface stub freshness', () => { + for (const spec of ALL_INTERFACES) { + describe(spec.interfaceName, () => { + let artifactExists = false; + beforeAll(async () => { + artifactExists = await fs + .access(spec.artifactPath) + .then(() => true) + .catch(() => false); + }); + + it('on-disk .gen.nr matches the freshly-rendered output', async () => { + if (!artifactExists) { + // Artifact is produced by `./bootstrap.sh build` (or `nargo compile` + + // `bb aztec_process` for the noir-contracts package). Skip with a clear message + // rather than fail when the artifact has not been built yet — the dedicated CI + // job that runs this test ensures the artifact is on disk before invoking jest. + console.warn(`Skipping ${spec.interfaceName}: ${spec.artifactPath} not found.`); + return; + } + const artifact = JSON.parse(await fs.readFile(spec.artifactPath, 'utf8')) as NoirCompiledContract; + const expected = renderInterfaceFile(spec, artifact); + const actual = await fs.readFile(spec.outputPath, 'utf8'); + + if (actual !== expected) { + throw new Error(REGEN_HINT); + } + }); + + it('render is deterministic for the same artifact', async () => { + if (!artifactExists) { + return; + } + const artifact = JSON.parse(await fs.readFile(spec.artifactPath, 'utf8')) as NoirCompiledContract; + expect(renderInterfaceFile(spec, artifact)).toEqual(renderInterfaceFile(spec, artifact)); + }); + }); + } +}); diff --git a/yarn-project/standard-contracts/src/standard-interfaces/generate_interfaces.ts b/yarn-project/standard-contracts/src/standard-interfaces/generate_interfaces.ts new file mode 100644 index 000000000000..4af1894112ce --- /dev/null +++ b/yarn-project/standard-contracts/src/standard-interfaces/generate_interfaces.ts @@ -0,0 +1,297 @@ +// Reusable renderers for the autogenerated Noir interface stubs that wrap +// `AuthRegistry` and `PublicChecks` external calls. +// +// Consumed by: +// - `../scripts/generate_interfaces.ts` — the CLI that writes the two committed `.gen.nr` files. +// - `./generate_interfaces.test.ts` — the CI freshness gate that re-renders from the +// freshly-built artifacts and asserts byte-equality against the on-disk values. +// +// Why generated instead of hand-written: the function selectors in the wrappers (e.g. +// `consume((Field),Field)`) must stay in lockstep with the contracts' external function +// signatures. A drifted hand-coded string compiles fine but lands public calls at the +// wrong dispatcher entry. Driving the file from the artifact + a byte-equality test +// removes that whole failure mode. +import { decodeFunctionSignature } from '@aztec/stdlib/abi'; +import type { ABIParameter, AbiType } from '@aztec/stdlib/abi'; +import { AZTEC_PUBLIC_ATTRIBUTE, AZTEC_VIEW_ATTRIBUTE, type NoirCompiledContract } from '@aztec/stdlib/noir'; + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = path.resolve(__dirname, '../../../../'); + +export const AZTEC_NR_SRC_DIR = path.join(REPO_ROOT, 'noir-projects/aztec-nr/aztec/src'); + +export type InterfaceSpec = { + /** Name of the generated struct + the standard contract whose external surface it wraps. */ + interfaceName: string; + /** Absolute path to the compiled artifact JSON. */ + artifactPath: string; + /** Absolute path where the generated `.gen.nr` should be written. */ + outputPath: string; + /** Relative path used in the generated file's header to point readers at this script. */ + generatorRelPath: string; +}; + +const NOIR_DISPATCH_FN_NAME = 'public_dispatch'; + +export const AUTH_REGISTRY_INTERFACE: InterfaceSpec = { + interfaceName: 'AuthRegistryInterface', + artifactPath: path.join(REPO_ROOT, 'noir-projects/noir-contracts/target/auth_registry_contract-AuthRegistry.json'), + outputPath: path.join(AZTEC_NR_SRC_DIR, 'authwit/auth_registry_interface.nr'), + generatorRelPath: 'yarn-project/standard-contracts/src/scripts/generate_interfaces.ts', +}; + +export const PUBLIC_CHECKS_INTERFACE: InterfaceSpec = { + interfaceName: 'PublicChecksInterface', + artifactPath: path.join(REPO_ROOT, 'noir-projects/noir-contracts/target/public_checks_contract-PublicChecks.json'), + outputPath: path.join(AZTEC_NR_SRC_DIR, 'public_checks_interface.nr'), + generatorRelPath: 'yarn-project/standard-contracts/src/scripts/generate_interfaces.ts', +}; + +export const ALL_INTERFACES: InterfaceSpec[] = [AUTH_REGISTRY_INTERFACE, PUBLIC_CHECKS_INTERFACE]; + +/** + * A single external public function on a standard contract, narrowed to what the renderer needs. + */ +type ExternalFn = { + /** External function name as it appears at the dispatcher. */ + name: string; + /** Whether the function is annotated `#[view]`. */ + isView: boolean; + parameters: ABIParameter[]; + /** `null` if the function returns `()`; otherwise the single ABI return type. */ + returnType: AbiType | null; +}; + +/** + * Extracts the externally-callable public functions from a compiled artifact. Skips the + * synthesized `public_dispatch` entry; includes `#[only_self]` since it is still routable + * via the dispatcher and we want the wrapper to expose every signature the dispatcher does. + */ +export function externalPublicFunctions(artifact: NoirCompiledContract): ExternalFn[] { + const out: ExternalFn[] = []; + for (const fn of artifact.functions) { + if (!fn.custom_attributes.includes(AZTEC_PUBLIC_ATTRIBUTE)) { + continue; + } + if (fn.name === NOIR_DISPATCH_FN_NAME) { + continue; + } + out.push({ + name: fn.name, + isView: fn.custom_attributes.includes(AZTEC_VIEW_ATTRIBUTE), + parameters: fn.abi.parameters, + returnType: fn.abi.return_type?.abi_type ?? null, + }); + } + // Sort by name for deterministic output regardless of artifact ordering. + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; +} + +/** + * Maps an ABI type to the Noir type name to use in the generated method signature. + */ +function noirTypeName(type: AbiType): string { + switch (type.kind) { + case 'field': + return 'Field'; + case 'boolean': + return 'bool'; + case 'integer': + return type.sign === 'signed' ? `i${type.width}` : `u${type.width}`; + case 'struct': { + // Only `AztecAddress` is supported as a struct parameter today — the two standard + // contracts we wrap don't expose any other user-defined types at their external + // boundary. Extend this if a new standard contract needs more. + if (type.path.endsWith('::AztecAddress')) { + return 'AztecAddress'; + } + throw new Error(`Unsupported struct type in generated interface: ${type.path}`); + } + default: + throw new Error(`Unsupported parameter kind in generated interface: ${type.kind}`); + } +} + +/** + * Maps an ABI type to the number of `Field`s it serializes into for the args array length. + */ +function serializedLen(type: AbiType): number { + switch (type.kind) { + case 'field': + case 'boolean': + case 'integer': + return 1; + case 'struct': { + // Same as above: only `AztecAddress`, which serializes to one Field (its `inner`). + if (type.path.endsWith('::AztecAddress')) { + return 1; + } + throw new Error(`Unsupported struct type in generated interface: ${type.path}`); + } + default: + throw new Error(`Unsupported parameter kind in generated interface: ${type.kind}`); + } +} + +/** + * Renders the `[, , ...]` args array passed to `PublicCall::new`/`PublicStaticCall::new`. + * Mirrors the cast pattern the hand-written stubs used (`x as Field` / `addr.to_field()`). + */ +function renderArgsArrayExpr(parameters: ABIParameter[]): string { + if (parameters.length === 0) { + return '[]'; + } + const exprs = parameters.map(p => paramToFieldExpr(p.name, p.type)); + return `[${exprs.join(', ')}]`; +} + +function paramToFieldExpr(name: string, type: AbiType): string { + switch (type.kind) { + case 'field': + return name; + case 'boolean': + case 'integer': + return `${name} as Field`; + case 'struct': + if (type.path.endsWith('::AztecAddress')) { + return `${name}.to_field()`; + } + throw new Error(`Unsupported struct type in generated interface: ${type.path}`); + default: + throw new Error(`Unsupported parameter kind in generated interface: ${type.kind}`); + } +} + +/** + * Renders the `PublicCall` / `PublicStaticCall` return type for a generated + * interface method. Mirrors what the `#[aztec]` macro stub generator emits. + */ +function renderCallReturnType(fn: ExternalFn): string { + const nameLen = Buffer.byteLength(fn.name, 'utf8'); + const argsLen = fn.parameters.reduce((acc, p) => acc + serializedLen(p.type), 0); + const returnT = fn.returnType === null ? '()' : noirTypeName(fn.returnType); + const callKind = fn.isView ? 'PublicStaticCall' : 'PublicCall'; + return `${callKind}<${nameLen}, ${argsLen}, ${returnT}>`; +} + +/** nargo fmt's hard wrap width — must mirror `max_width` in `noir-projects/aztec-nr/noirfmt.toml`. */ +const NARGO_FMT_MAX_WIDTH = 120; + +function renderMethodSignature(fn: ExternalFn, returnType: string): string { + const inlineParams = fn.parameters.map(p => `${p.name}: ${noirTypeName(p.type)}`).join(', '); + const inlineSignature = ` pub fn ${fn.name}(self${ + inlineParams.length === 0 ? '' : ', ' + inlineParams + }) -> ${returnType} {`; + + if (inlineSignature.length <= NARGO_FMT_MAX_WIDTH) { + return inlineSignature; + } + + // nargo fmt's wrap style places `self` and each parameter on its own line, with a trailing comma. + const wrappedParamLines = [' self', ...fn.parameters.map(p => ` ${p.name}: ${noirTypeName(p.type)}`)] + .map(l => `${l},`) + .join('\n'); + return [` pub fn ${fn.name}(`, wrappedParamLines, ` ) -> ${returnType} {`].join('\n'); +} + +function renderMethod(fn: ExternalFn): string { + const signature = decodeFunctionSignature(fn.name, fn.parameters); + const returnType = renderCallReturnType(fn); + const callKind = fn.isView ? 'PublicStaticCall' : 'PublicCall'; + const argsExpr = renderArgsArrayExpr(fn.parameters); + + const signatureLine = renderMethodSignature(fn, returnType); + const selectorLine = ` let selector = comptime { FunctionSelector::from_signature("${signature}") };`; + + // Emit the `${callKind}::new(...)` body in multi-line form unconditionally. This matches the + // style of the hand-written stub it is replacing and stays well within nargo fmt's 120-char + // wrap width so the formatter does not touch the file after we write it. + const constructorLines = [ + ` ${callKind}::new(`, + ` self.target_contract,`, + ` selector,`, + ` "${fn.name}",`, + ` ${argsExpr},`, + ` )`, + ]; + + return [signatureLine, selectorLine, ...constructorLines, ` }`].join('\n'); +} + +/** + * Determines which `PublicCall` / `PublicStaticCall` types need to be imported. + */ +function neededCallImports(fns: ExternalFn[]): string[] { + const needs = new Set(); + for (const fn of fns) { + needs.add(fn.isView ? 'PublicStaticCall' : 'PublicCall'); + } + return Array.from(needs).sort(); +} + +/** + * Renders the full generated `.gen.nr` file for an interface, byte-for-byte. + * + * The output is intentionally pre-wrapped (no lines >120 chars in the generated header / + * struct, and the method bodies are short) so `nargo fmt --check` does not perturb the file + * after the renderer writes it. The freshness gate compares this output byte-for-byte to + * what is committed on disk. + */ +export function renderInterfaceFile(spec: InterfaceSpec, artifact: NoirCompiledContract): string { + const fns = externalPublicFunctions(artifact); + if (fns.length === 0) { + throw new Error(`No external public functions found in ${spec.artifactPath}`); + } + + const callImports = neededCallImports(fns); + const callImportLine = + callImports.length === 1 + ? `use crate::context::calls::${callImports[0]};` + : `use crate::context::calls::{${callImports.join(', ')}};`; + + // `ToField` is only used when an `AztecAddress` parameter is rendered as `addr.to_field()`. + // Pull it in conditionally so we don't trip nargo's unused-import warning for the simpler + // interfaces (e.g. PublicChecks, which only takes primitive parameters). + const usesAddressParam = fns.some(fn => + fn.parameters.some(p => p.type.kind === 'struct' && p.type.path.endsWith('::AztecAddress')), + ); + const protocolImportItems = ['abis::function_selector::FunctionSelector', 'address::AztecAddress']; + if (usesAddressParam) { + protocolImportItems.push('traits::ToField'); + } + const protocolImportLine = `use crate::protocol::{${protocolImportItems.join(', ')}};`; + + const methods = fns.map(renderMethod).join('\n\n'); + + return `// GENERATED FILE - DO NOT EDIT +// +// Written by \`${spec.generatorRelPath}\` from the compiled +// \`${artifact.name}\` artifact. Regenerate with +// \`yarn workspace @aztec/standard-contracts run regen:standard-interfaces\`. +// +// The selectors below are derived via \`comptime { FunctionSelector::from_signature(...) }\` at +// Noir compile time, with the signature string emitted from the artifact's parameter list. This +// keeps the wrapper in lockstep with whatever the \`#[aztec]\` macro generates for the real +// contract; any drift between the contract's external signatures and this file fails the +// \`generate_interfaces.test.ts\` freshness gate. + +${callImportLine} +${protocolImportLine} + +pub struct ${spec.interfaceName} { + pub target_contract: AztecAddress, +} + +impl ${spec.interfaceName} { + pub fn at(target_contract: AztecAddress) -> Self { + Self { target_contract } + } + +${methods} +} +`; +}