diff --git a/yarn-project/pxe/src/bin/check_oracle_version.test.ts b/yarn-project/pxe/src/bin/check_oracle_version.test.ts deleted file mode 100644 index 9e4c39619e9f..000000000000 --- a/yarn-project/pxe/src/bin/check_oracle_version.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; - -import { readNumericGlobal } from './oracle_version_helpers.js'; - -describe('readNumericGlobal', () => { - let dir: string; - - beforeAll(() => { - dir = mkdtempSync(join(tmpdir(), 'oracle-version-')); - }); - - afterAll(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - const writeFixture = (name: string, contents: string): string => { - const path = join(dir, name); - writeFileSync(path, contents); - return path; - }; - - it('extracts the value from a Noir global declaration', () => { - const path = writeFixture( - 'version.nr', - `pub global ORACLE_VERSION_MAJOR: Field = 28;\npub global ORACLE_VERSION_MINOR: Field = 3;\n`, - ); - expect(readNumericGlobal(path, 'ORACLE_VERSION_MAJOR')).toBe(28); - expect(readNumericGlobal(path, 'ORACLE_VERSION_MINOR')).toBe(3); - }); - - it('extracts the value from a TypeScript const declaration', () => { - const path = writeFixture('oracle_version.ts', `export const ORACLE_VERSION_MAJOR = 28;\n`); - expect(readNumericGlobal(path, 'ORACLE_VERSION_MAJOR')).toBe(28); - }); - - it('reads the declaration and ignores later usages of the constant', () => { - const path = writeFixture( - 'usage.nr', - `pub global TXE_ORACLE_VERSION_MAJOR: Field = 5;\nfoo(TXE_ORACLE_VERSION_MAJOR, TXE_ORACLE_VERSION_MINOR);\n`, - ); - expect(readNumericGlobal(path, 'TXE_ORACLE_VERSION_MAJOR')).toBe(5); - }); - - it('does not match a constant whose name extends the requested name', () => { - const path = writeFixture('prefixed.nr', `pub global SOME_ORACLE_VERSION_MAJOR: Field = 9;\n`); - expect(() => readNumericGlobal(path, 'ORACLE_VERSION_MAJOR')).toThrow(/Could not find numeric global/); - }); - - it('throws when the global is absent', () => { - const path = writeFixture('empty.nr', `pub global SOMETHING_ELSE: Field = 1;\n`); - expect(() => readNumericGlobal(path, 'ORACLE_VERSION_MAJOR')).toThrow(/Could not find numeric global/); - }); -}); diff --git a/yarn-project/pxe/src/bin/check_oracle_version.ts b/yarn-project/pxe/src/bin/check_oracle_version.ts index 20787d1f59fa..0475415dd69e 100644 --- a/yarn-project/pxe/src/bin/check_oracle_version.ts +++ b/yarn-project/pxe/src/bin/check_oracle_version.ts @@ -4,36 +4,27 @@ import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { ORACLE_INTERFACE_HASH, ORACLE_VERSION_MAJOR } from '../oracle_version.js'; -import { getOracleInterfaceSignature, readNumericGlobal } from './oracle_version_helpers.js'; +import { getOracleRegistrySignature, readNumericGlobal } from './oracle_version_helpers.js'; /** * Verifies that the Oracle interface matches the expected interface hash. * * The Oracle interface needs to be versioned to ensure compatibility between Aztec.nr and PXE. This function computes - * a hash of the Oracle interface and compares it against a known hash. If they don't match, it means the interface has - * changed and the oracle version needs to be bumped: - * - If the change is backward-breaking (e.g. removing/renaming an oracle), bump ORACLE_VERSION_MAJOR. + * a hash of the `ORACLE_REGISTRY` declaration (where each oracle's parameter names, parameter types, and return type + * live) and compares it against a known hash. If they don't match, it means the interface has changed and the oracle + * version needs to be bumped: + * - If the change is backward-breaking (e.g. removing/renaming an oracle, or changing its params/return), bump + * ORACLE_VERSION_MAJOR. * - If the change is an oracle addition (non-breaking), bump ORACLE_VERSION_MINOR. */ function assertOracleInterfaceMatches(): void { - const excludedProps = [ - 'handler', - 'constructor', - 'toACIRCallback', - 'handlerAsMisc', - 'handlerAsUtility', - 'handlerAsPrivate', - ]; - - // Get the path to Oracle.ts source file - // The script runs from dest/bin/ after compilation, so we need to go up to the package root - // then into src/ to find the source file + // The script runs from dest/bin/ after compilation, so we go up to the package root then into src/ to find + // the source file. const currentDir = dirname(fileURLToPath(import.meta.url)); - // Go up from dest/bin/ or src/bin/ to the package root (pxe/), then into src/ const packageRoot = dirname(dirname(currentDir)); // Go up from bin/ to pxe/ - const oracleSourcePath = join(packageRoot, 'src/contract_function_simulator/oracle/oracle.ts'); + const registrySourcePath = join(packageRoot, 'src/contract_function_simulator/oracle/oracle_registry.ts'); - const oracleInterfaceSignature = getOracleInterfaceSignature(oracleSourcePath, ['Oracle'], excludedProps); + const oracleInterfaceSignature = getOracleRegistrySignature(registrySourcePath, 'ORACLE_REGISTRY'); // We use keccak256 here just because we already have it in the dependencies. const oracleInterfaceHash = keccak256String(oracleInterfaceSignature); diff --git a/yarn-project/pxe/src/bin/index.ts b/yarn-project/pxe/src/bin/index.ts index 7d43f68aeb61..44df032436f1 100644 --- a/yarn-project/pxe/src/bin/index.ts +++ b/yarn-project/pxe/src/bin/index.ts @@ -1 +1,5 @@ -export { getOracleInterfaceSignature, readNumericGlobal } from './oracle_version_helpers.js'; +export { + getOracleInterfaceSignature, + getOracleRegistrySignature, + readNumericGlobal, +} from './oracle_version_helpers.js'; diff --git a/yarn-project/pxe/src/bin/oracle_version_helpers.test.ts b/yarn-project/pxe/src/bin/oracle_version_helpers.test.ts new file mode 100644 index 000000000000..c2ea6c81f63c --- /dev/null +++ b/yarn-project/pxe/src/bin/oracle_version_helpers.test.ts @@ -0,0 +1,130 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { getOracleRegistrySignature, readNumericGlobal } from './oracle_version_helpers.js'; + +describe('readNumericGlobal', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'oracle-version-')); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('extracts the value from a Noir global declaration', () => { + const path = writeFixture( + dir, + 'version.nr', + `pub global ORACLE_VERSION_MAJOR: Field = 28;\npub global ORACLE_VERSION_MINOR: Field = 3;\n`, + ); + expect(readNumericGlobal(path, 'ORACLE_VERSION_MAJOR')).toBe(28); + expect(readNumericGlobal(path, 'ORACLE_VERSION_MINOR')).toBe(3); + }); + + it('extracts the value from a TypeScript const declaration', () => { + const path = writeFixture(dir, 'oracle_version.ts', `export const ORACLE_VERSION_MAJOR = 28;\n`); + expect(readNumericGlobal(path, 'ORACLE_VERSION_MAJOR')).toBe(28); + }); + + it('reads the declaration and ignores later usages of the constant', () => { + const path = writeFixture( + dir, + 'usage.nr', + `pub global TXE_ORACLE_VERSION_MAJOR: Field = 5;\nfoo(TXE_ORACLE_VERSION_MAJOR, TXE_ORACLE_VERSION_MINOR);\n`, + ); + expect(readNumericGlobal(path, 'TXE_ORACLE_VERSION_MAJOR')).toBe(5); + }); + + it('does not match a constant whose name extends the requested name', () => { + const path = writeFixture(dir, 'prefixed.nr', `pub global SOME_ORACLE_VERSION_MAJOR: Field = 9;\n`); + expect(() => readNumericGlobal(path, 'ORACLE_VERSION_MAJOR')).toThrow(/Could not find numeric global/); + }); + + it('throws when the global is absent', () => { + const path = writeFixture(dir, 'empty.nr', `pub global SOMETHING_ELSE: Field = 1;\n`); + expect(() => readNumericGlobal(path, 'ORACLE_VERSION_MAJOR')).toThrow(/Could not find numeric global/); + }); +}); + +describe('getOracleRegistrySignature', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'oracle-registry-')); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const SAMPLE_REGISTRY = `export const ORACLE_REGISTRY = { + aztec_utl_foo: makeEntry({ + params: [ + { name: 'a', type: U32 }, + { name: 'b', type: OPTION(AZTEC_ADDRESS) }, + ], + returnType: BOOL, + }), + aztec_utl_bar: makeEntry({ returnType: FIELD }), + aztec_prv_baz: makeEntry({ params: [{ name: 'x', type: FIELD }] }), + aztec_prv_qux: makeEntry(), + } satisfies Record; +`; + + it('builds a sorted signature of names, ordered typed params, and return types', () => { + const path = writeFixture(dir, 'registry.ts', SAMPLE_REGISTRY); + expect(getOracleRegistrySignature(path, 'ORACLE_REGISTRY')).toBe( + 'aztec_prv_baz(x: FIELD): void\n' + + 'aztec_prv_qux(): void\n' + + 'aztec_utl_bar(): FIELD\n' + + 'aztec_utl_foo(a: U32, b: OPTION(AZTEC_ADDRESS)): BOOL', + ); + }); + + it('changes when a parameter type changes (the gap the Oracle-class hash missed)', () => { + const before = writeFixture(dir, 'before.ts', SAMPLE_REGISTRY); + const after = writeFixture(dir, 'after.ts', SAMPLE_REGISTRY.replace('type: OPTION(AZTEC_ADDRESS)', 'type: FIELD')); + expect(getOracleRegistrySignature(after, 'ORACLE_REGISTRY')).not.toBe( + getOracleRegistrySignature(before, 'ORACLE_REGISTRY'), + ); + }); + + it('is insensitive to formatting of the type expressions', () => { + const reformatted = writeFixture( + dir, + 'reformatted.ts', + SAMPLE_REGISTRY.replace('OPTION(AZTEC_ADDRESS)', 'OPTION(\n AZTEC_ADDRESS\n )'), + ); + const original = writeFixture(dir, 'original.ts', SAMPLE_REGISTRY); + expect(getOracleRegistrySignature(reformatted, 'ORACLE_REGISTRY')).toBe( + getOracleRegistrySignature(original, 'ORACLE_REGISTRY'), + ); + }); + + it('throws on spread members, which are not yet supported', () => { + const path = writeFixture( + dir, + 'spread.ts', + `export const ORACLE_REGISTRY = { + ...BASE_REGISTRY, + aztec_utl_foo: makeEntry({ returnType: FIELD }), + } satisfies Record;\n`, + ); + expect(() => getOracleRegistrySignature(path, 'ORACLE_REGISTRY')).toThrow(/Spread elements are not supported/); + }); + + it('throws when the registry is absent', () => { + const path = writeFixture(dir, 'absent.ts', `export const SOMETHING_ELSE = {};\n`); + expect(() => getOracleRegistrySignature(path, 'ORACLE_REGISTRY')).toThrow(/Could not find oracle registry/); + }); +}); + +const writeFixture = (dir: string, name: string, contents: string): string => { + const path = join(dir, name); + writeFileSync(path, contents); + return path; +}; diff --git a/yarn-project/pxe/src/bin/oracle_version_helpers.ts b/yarn-project/pxe/src/bin/oracle_version_helpers.ts index a0026556b5c4..00bdbcbb7979 100644 --- a/yarn-project/pxe/src/bin/oracle_version_helpers.ts +++ b/yarn-project/pxe/src/bin/oracle_version_helpers.ts @@ -60,6 +60,58 @@ export function getOracleInterfaceSignature(sourcePath: string, targets: string[ return methodSignatures.join(''); } +/** + * Extracts a deterministic signature string from an oracle registry object literal (e.g. PXE's `ORACLE_REGISTRY`). + * + * Reads the registry declaration where each oracle's wire ABI lives: the ordered parameter names with their + * `TypeMapping` expressions and the return type. The resulting hash is sensitive to parameter, type, and return + * changes, not just oracle additions and removals. + * + * Type expressions are captured as their source text (e.g. `OPTION(AZTEC_ADDRESS)`, `BOUNDED_VEC(NOTE)`), so the + * signature tracks the composition of types. However, if the internal serialization logic of a `TypeMapping` constant + * (e.g. `FIELD`) changes without the constant being renamed, the hash will not change. + * + * @example + * // Given a registry like: + * // export const ORACLE_REGISTRY = { + * // aztec_utl_foo: makeEntry({ params: [{ name: 'a', type: U32 }], returnType: BOOL }), + * // aztec_prv_bar: makeEntry(), + * // } satisfies Record; + * // + * // Returns (sorted, newline-joined): + * // "aztec_prv_bar(): void\naztec_utl_foo(a: U32): BOOL" + * + * @param sourcePath - Absolute path to the TypeScript source file declaring the registry. + * @param registryName - Name of the registry constant to read (e.g. `ORACLE_REGISTRY`). + */ +export function getOracleRegistrySignature(sourcePath: string, registryName: string): string { + const sourceCode = readFileSync(sourcePath, 'utf-8'); + const sourceFile = ts.createSourceFile(sourcePath, sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + + const registry = findObjectLiteral(sourceFile, registryName); + if (!registry) { + throw new Error(`Could not find oracle registry '${registryName}' in ${sourcePath}.`); + } + + const oracleSignatures = registry.properties.map(property => { + if (!ts.isPropertyAssignment(property)) { + throw new Error( + `Unexpected member in oracle registry '${registryName}': ${property.getText(sourceFile)}. Spread elements ` + + `are not supported.`, + ); + } + + const oracleName = getPropertyName(property.name, sourceFile); + const { params, returnType } = extractRegistryEntry(property.initializer, sourceFile); + const paramSignatures = params.map(param => `${param.name}: ${param.type}`); + return `${oracleName}(${paramSignatures.join(', ')}): ${returnType}`; + }); + + oracleSignatures.sort(); + + return oracleSignatures.join('\n'); +} + /** * Reads an integer-valued global constant from a Noir or TypeScript source file. * @@ -112,6 +164,7 @@ function extractParameterName(param: ts.ParameterDeclaration, sourceFile: ts.Sou return (name as ts.Node).getText(sourceFile); } +/** Returns the source text of a type annotation, or `'void'` if absent. */ function extractTypeString(typeNode: ts.TypeNode | undefined, sourceFile: ts.SourceFile): string { if (!typeNode) { return 'void'; @@ -119,3 +172,140 @@ function extractTypeString(typeNode: ts.TypeNode | undefined, sourceFile: ts.Sou return typeNode.getText(sourceFile).replace(/\s+/g, ' ').trim(); } + +/** + * Finds a top-level object-literal-valued constant by name, unwrapping `satisfies`/`as`/parenthesized wrappers. + * + * @example + * // `const REGISTRY = { ... } satisfies Record` => the `{ ... }` ObjectLiteralExpression + */ +function findObjectLiteral(sourceFile: ts.SourceFile, name: string): ts.ObjectLiteralExpression | undefined { + let result: ts.ObjectLiteralExpression | undefined; + + function visit(node: ts.Node) { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer) { + result = unwrapObjectLiteral(node.initializer); + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return result; +} + +/** Peels `satisfies`, `as`, and parenthesized wrappers off an expression to reach the underlying object literal. */ +function unwrapObjectLiteral(node: ts.Expression): ts.ObjectLiteralExpression | undefined { + let current = node; + while (ts.isSatisfiesExpression(current) || ts.isAsExpression(current) || ts.isParenthesizedExpression(current)) { + current = current.expression; + } + return ts.isObjectLiteralExpression(current) ? current : undefined; +} + +/** + * Extracts the ordered `params` (names + type expressions) and `returnType` from a `makeEntry({ ... })` call. + * + * @example + * // makeEntry({ params: [{ name: 'a', type: OPTION(FIELD) }], returnType: BOOL }) + * // => { params: [{ name: 'a', type: 'OPTION(FIELD)' }], returnType: 'BOOL' } + * // + * // makeEntry() + * // => { params: [], returnType: 'void' } + */ +function extractRegistryEntry( + initializer: ts.Expression, + sourceFile: ts.SourceFile, +): { params: { name: string; type: string }[]; returnType: string } { + if (!ts.isCallExpression(initializer)) { + throw new Error(`Expected a makeEntry(...) call but got: ${initializer.getText(sourceFile)}`); + } + + const [arg] = initializer.arguments; + if (arg === undefined) { + return { params: [], returnType: 'void' }; + } + if (!ts.isObjectLiteralExpression(arg)) { + throw new Error(`Expected a makeEntry object argument but got: ${arg.getText(sourceFile)}`); + } + + let params: { name: string; type: string }[] = []; + let returnType = 'void'; + + arg.properties.forEach(property => { + if (!ts.isPropertyAssignment(property)) { + throw new Error(`Unexpected makeEntry property: ${property.getText(sourceFile)}`); + } + const key = getPropertyName(property.name, sourceFile); + if (key === 'params') { + params = extractRegistryParams(property.initializer, sourceFile); + } else if (key === 'returnType') { + returnType = normalizeExpressionText(property.initializer, sourceFile); + } else { + throw new Error(`Unexpected makeEntry property '${key}'.`); + } + }); + + return { params, returnType }; +} + +/** + * Extracts `{ name, type }` pairs from the `params` array literal inside a `makeEntry` call. + */ +function extractRegistryParams( + initializer: ts.Expression, + sourceFile: ts.SourceFile, +): { name: string; type: string }[] { + if (!ts.isArrayLiteralExpression(initializer)) { + throw new Error(`Expected a params array but got: ${initializer.getText(sourceFile)}`); + } + + return initializer.elements.map(element => { + if (!ts.isObjectLiteralExpression(element)) { + throw new Error(`Expected a param object but got: ${element.getText(sourceFile)}`); + } + + let name: string | undefined; + let type: string | undefined; + element.properties.forEach(property => { + if (!ts.isPropertyAssignment(property)) { + throw new Error(`Unexpected param property: ${property.getText(sourceFile)}`); + } + const key = getPropertyName(property.name, sourceFile); + if (key === 'name') { + if (!ts.isStringLiteralLike(property.initializer)) { + throw new Error(`Expected a string literal param name but got: ${property.initializer.getText(sourceFile)}`); + } + name = property.initializer.text; + } else if (key === 'type') { + type = normalizeExpressionText(property.initializer, sourceFile); + } else { + throw new Error(`Unexpected param property '${key}'.`); + } + }); + + if (name === undefined || type === undefined) { + throw new Error(`Param missing 'name' or 'type': ${element.getText(sourceFile)}`); + } + return { name, type }; + }); +} + +/** Returns the text of an identifier or string-literal property name, throwing on computed or numeric names. */ +function getPropertyName(name: ts.PropertyName, sourceFile: ts.SourceFile): string { + if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) { + return name.text; + } + throw new Error(`Unsupported property name: ${name.getText(sourceFile)}`); +} + +/** + * Returns the source text of an expression with all whitespace stripped for format-insensitive comparison. + * + * @example + * // `OPTION(\n AZTEC_ADDRESS\n)` => `"OPTION(AZTEC_ADDRESS)"` + */ +function normalizeExpressionText(node: ts.Expression, sourceFile: ts.SourceFile): string { + // Type expressions are TypeMapping references and calls (identifiers, parentheses, commas, numbers) with no + // meaningful whitespace, so we strip it entirely to keep the signature stable across reformatting. + return node.getText(sourceFile).replace(/\s+/g, ''); +} diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index c2c0ed2ffe72..bedaddcedb86 100644 --- a/yarn-project/pxe/src/oracle_version.ts +++ b/yarn-project/pxe/src/oracle_version.ts @@ -13,10 +13,10 @@ export const ORACLE_VERSION_MAJOR = 29; export const ORACLE_VERSION_MINOR = 0; -/// This hash is computed from the Oracle interface and is used to detect when that interface changes. When it does, -/// you need to either: +/// This hash is computed from the `ORACLE_REGISTRY` declaration (each oracle's name, ordered parameter names and +/// types, and return type) and is used to detect when the oracle interface changes. When it does, you need to either: /// - increment `ORACLE_VERSION_MAJOR` and reset `ORACLE_VERSION_MINOR` to zero if the change is breaking, or /// - increment only `ORACLE_VERSION_MINOR` if the change is additive (a new oracle was added). /// /// These constants must be kept in sync between this file and `noir-projects/aztec-nr/aztec/src/oracle/version.nr`. -export const ORACLE_INTERFACE_HASH = '157fe8fa79f0e5a25db3d61341f9bcf5b43cb68367bea497204006daa48751b6'; +export const ORACLE_INTERFACE_HASH = 'ee38f06b961a8134f9bf6abc644f422ac39df42d964deec5838fe2b07eace7e0';