From 83a5670177d9842692c48d6e8fb5d2ddc24f80fb Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Fri, 19 Jun 2026 22:57:56 -0300 Subject: [PATCH 1/6] refactor(pxe): shape-based oracle (de)serialization --- .../src/contract_function_simulator/index.ts | 1 + .../noir-structs/option.ts | 21 +- .../oracle/oracle_registry.test.ts | 128 -------- .../oracle/oracle_registry.ts | 6 +- .../oracle/oracle_type_mappings.ts | 279 +++++++++++++----- .../oracle/private_execution_oracle.ts | 6 +- .../oracle/utility_execution_oracle.ts | 14 +- .../src/oracle/test-resolver/resolver.test.ts | 2 +- .../txe/src/oracle/txe_oracle_registry.ts | 35 ++- 9 files changed, 256 insertions(+), 236 deletions(-) delete mode 100644 yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.test.ts diff --git a/yarn-project/pxe/src/contract_function_simulator/index.ts b/yarn-project/pxe/src/contract_function_simulator/index.ts index 15ec0a51e5a0..2d342eb420b5 100644 --- a/yarn-project/pxe/src/contract_function_simulator/index.ts +++ b/yarn-project/pxe/src/contract_function_simulator/index.ts @@ -39,6 +39,7 @@ export { type InputSlot, type MaybePromise, type OutputSlot, + type SlotShape, type TypeMapping, } from './oracle/oracle_type_mappings.js'; export { ExecutionNoteCache } from './execution_note_cache.js'; diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/option.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/option.ts index 14347d4d64a2..dca1b9f64826 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/option.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/option.ts @@ -8,7 +8,9 @@ export class Option { private constructor( public readonly value: T | undefined, - public readonly template: T | undefined, + // `unknown` (not the descriptor shape) keeps this generic Option decoupled from the serialization layer's + // `{ length }`/`{ maxLength }` size descriptors; only the None-serialization path inspects it. + public readonly size: unknown, ) {} /** @@ -26,25 +28,22 @@ export class Option { /** * Construct an absent Option. * - * When serialized back to ACVM, the `None` case must produce the same number of fields as `Some`. - * For types whose wire size varies per call site (`BoundedVec`, `FixedArray`), pass a `template` so the - * serializer knows how many zero fields to emit. Omit the template when the Option will not be - * re-serialized (e.g. deserialized input params). - * - * @param template - A representative empty `T` whose serialization determines the zero-filled wire format. + * When serialized back to ACVM, the `None` case must produce the same number of fields as `Some`. For an inner whose + * wire size varies per call site (`BoundedVec`, an array), pass a `size` descriptor so the inner type's shape can + * resolve how many zero fields to emit; fixed-size inners take no argument. * * @example None for a fixed-size type: * ```ts - * return Option.none(AztecAddress.ZERO); + * return Option.none(); * ``` * * @example None for a dynamic-size type: * ```ts - * return Option.none(BoundedVec.empty({ maxLength: ciphertext.maxLength })); + * return Option.none({ maxLength: ciphertext.maxLength }); * ``` */ - static none(template?: T): Option { - return new Option(undefined, template); + static none(size?: unknown): Option { + return new Option(undefined, size); } /** diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.test.ts deleted file mode 100644 index 1de6f62f51dc..000000000000 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Fr } from '@aztec/foundation/curves/bn254'; -import { FieldReader } from '@aztec/foundation/serialize'; -import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { AppTaggingSecretKind, Tag } from '@aztec/stdlib/logs'; - -import { EphemeralArrayService } from '../ephemeral_array_service.js'; -import { LogRetrievalRequest } from '../noir-structs/log_retrieval_request.js'; -import { - BOUNDED_VEC, - BYTE, - DELIVERY_MODE, - EPHEMERAL_ARRAY, - FIELD, - LOG_RETRIEVAL_REQUEST, - type TypeMapping, - U32, -} from './oracle_registry.js'; - -function deserialize(mapping: TypeMapping, value: Fr): T { - const reader = new FieldReader([value]); - return mapping.deserialization!.fn([reader]); -} - -describe('oracle_registry type mappings', () => { - describe('U32', () => { - it('deserializes a valid u32', () => { - expect(deserialize(U32, new Fr(42))).toBe(42); - }); - - it('deserializes u32 max', () => { - expect(deserialize(U32, new Fr(0xffffffffn))).toBe(0xffffffff); - }); - - it('rejects values exceeding u32 max', () => { - expect(() => deserialize(U32, new Fr(0x100000000n))).toThrow('U32 overflow'); - }); - }); - - describe('BYTE', () => { - it('deserializes a valid byte', () => { - expect(deserialize(BYTE, new Fr(0))).toBe(0); - }); - - it('deserializes byte max', () => { - expect(deserialize(BYTE, new Fr(255))).toBe(255); - }); - - it('rejects values exceeding u8 max', () => { - expect(() => deserialize(BYTE, new Fr(256))).toThrow('BYTE overflow'); - }); - }); - - describe('DELIVERY_MODE', () => { - it('maps onchain unconstrained delivery to unconstrained tagging', () => { - expect(deserialize(DELIVERY_MODE, new Fr(2))).toBe(AppTaggingSecretKind.UNCONSTRAINED); - }); - - it('maps onchain constrained delivery to constrained tagging', () => { - expect(deserialize(DELIVERY_MODE, new Fr(3))).toBe(AppTaggingSecretKind.CONSTRAINED); - }); - - it('rejects offchain delivery', () => { - expect(() => deserialize(DELIVERY_MODE, new Fr(1))).toThrow('Unrecognized delivery mode for tagging'); - }); - }); - - describe('BOUNDED_VEC', () => { - it('deserializes when capacity equals length', () => { - const a = Fr.random(); - const b = Fr.random(); - const bv = deserializeBoundedVec(FIELD, [a, b], 2); - expect(bv.data).toEqual([a, b]); - expect(bv.maxLength).toBe(2); - }); - - it('deserializes when capacity exceeds length', () => { - const a = Fr.random(); - const bv = deserializeBoundedVec(FIELD, [a], 4); - expect(bv.data).toEqual([a]); - expect(bv.maxLength).toBe(4); - }); - - it('deserializes an empty vec with nonzero capacity', () => { - const bv = deserializeBoundedVec(FIELD, [], 3); - expect(bv.data).toEqual([]); - expect(bv.maxLength).toBe(3); - }); - }); - - describe('EPHEMERAL_ARRAY', () => { - it('deserializes well-formed rows', () => { - const a = Fr.random(); - const b = Fr.random(); - expect(readEphemeralArray(FIELD, [[a], [b]])).toEqual([a, b]); - }); - - it('rejects a row with too few fields', () => { - expect(() => readEphemeralArray(FIELD, [[]])).toThrow('Not enough fields to be consumed.'); - }); - - it('rejects a row with trailing fields', () => { - expect(() => readEphemeralArray(FIELD, [[Fr.random(), Fr.random()]])).toThrow('unexpected trailing field(s)'); - }); - - it('rejects a multi-field row with trailing fields', async () => { - const row = new LogRetrievalRequest(await AztecAddress.random(), new Tag(Fr.random())).toFields(); - expect(() => readEphemeralArray(LOG_RETRIEVAL_REQUEST, [[...row, Fr.random()]])).toThrow( - 'unexpected trailing field(s)', - ); - }); - }); -}); - -/** Deserializes a BoundedVec from `data` (padded to `capacity` with Fr.ZERO) and a length field. */ -function deserializeBoundedVec(element: TypeMapping, data: Fr[], capacity: number) { - const padded = [...data, ...Array(capacity - data.length).fill(Fr.ZERO)]; - const storageReader = new FieldReader(padded); - const lengthReader = new FieldReader([new Fr(data.length)]); - return BOUNDED_VEC(element).deserialization!.fn([storageReader, lengthReader]); -} - -/** Reads an input-mode EphemeralArray backed by `rows`, the way the registry does when deserializing an oracle param. */ -function readEphemeralArray(element: TypeMapping, rows: Fr[][]): T[] { - const service = new EphemeralArrayService(); - const slot = service.newArray(rows); - const array = EPHEMERAL_ARRAY(element).deserialization!.fn([new FieldReader([slot])]); - return array.readAll(service); -} diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts index c0bb2dc1dbc1..0f94816f4243 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts @@ -48,6 +48,7 @@ import { U32, UTILITY_CONTEXT, assertReadersConsumed, + slotsOf, } from './oracle_type_mappings.js'; export { @@ -59,6 +60,7 @@ export { BOUNDED_VEC, BUFFER, BYTE, + CONTRACT_CLASS_LOG_INPUT, DELIVERY_MODE, EPHEMERAL_ARRAY, EVENT_VALIDATION_REQUEST, @@ -75,9 +77,11 @@ export { PROVIDED_SECRET, STR, U32, + slotsOf, type InputSlot, type MaybePromise, type OutputSlot, + type SlotShape, type TypeMapping, } from './oracle_type_mappings.js'; @@ -545,7 +549,7 @@ export function makeEntry new FieldReader(slot.map(hex => Fr.fromString(hex)))); diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts index 931eb11c5302..f8cf2b1c840d 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts @@ -1,6 +1,6 @@ import { BLOCK_HEADER_LENGTH, - KEY_VALIDATION_REQUEST_LENGTH, + CONTRACT_INSTANCE_LENGTH, L1_TO_L2_MSG_TREE_HEIGHT, MAX_CONTRACT_CLASS_LOGS_PER_TX, MAX_L2_TO_L1_MSGS_PER_TX, @@ -58,6 +58,18 @@ export type InputSlot = ACVMField[]; /** One ACVM output slot: a scalar hex string or an array of hex strings. */ export type OutputSlot = ACVMField | ACVMField[]; +/** + * Wire layout of a single slot: + * - `'scalar'` — a bare field (one `Fr`); on the ACVM/Noir wire a `Field`. + * - `{ len }` — an array of exactly `len` fields (`Fr[]`); a `[Field; len]`. + * - `'variable'` — a slot whose field count is not statically known and cannot be sized on demand (a string, a + * length-prefixed struct). It contributes to the slot count but can never be zero-filled. + * - `{ lenFrom: fn }` — like `'variable'`, but its field count can be resolved from a runtime size descriptor. The + * descriptor's shape is type-specific (e.g. `{ length }`, `{ maxLength }`), so `size` is typed `any`: a single union + * can't type each mapping's distinct `lenFrom` under parameter contravariance. + */ +export type SlotShape = 'scalar' | { len: number } | 'variable' | { lenFrom: (size: any) => number }; + /** * Describes how to serialize and/or deserialize a single typed value to/from ACVM wire format. * Either side is optional — output-only types omit `deserialization`, input-only types omit `serialization`. @@ -68,18 +80,18 @@ export interface TypeMapping { fn: (value: T) => (Fr | Fr[])[]; }; deserialization?: { - /** Read a typed value from one FieldReader per consumed slot. */ + /** Read a typed value from its slots — one {@link FieldReader} per slot, as laid out by {@link shape}. */ fn: (readers: FieldReader[]) => T; - /** - * Number of InputSlots this type reads from. `deserialization.fn` receives one FieldReader per slot in `readers`. - * - * Examples: - * - `FIELD`, `U32`, `AZTEC_ADDRESS` — single slot → `slots: 1` - * - `OPTION(T)` — discriminant + inner slots → `slots: T.slots + 1` - * - `CONTRACT_CLASS_LOG_INPUT` — [addr], [fields], [len] → `slots: 3` - */ - slots: number; }; + /** + * The type's wire layout, one entry per slot. + * + * Examples: + * - `FIELD` → `['scalar']` // single slot + * - `OPTION(T)` → `['scalar', ...T.shape]` // [discriminant], [...inner.shape] + * - `CONTRACT_CLASS_LOG_INPUT` → `['scalar', 'variable', 'scalar']` // [addr], [fields], [len] + */ + shape: SlotShape[]; } export type MaybePromise = T | Promise; @@ -101,12 +113,14 @@ export function assertReadersConsumed(readers: FieldReader[]): void { export const FIELD: TypeMapping = { serialization: { fn: v => [v] }, - deserialization: { fn: ([reader]) => reader.readField(), slots: 1 }, + deserialization: { fn: ([reader]) => reader.readField() }, + shape: ['scalar'], }; export const BOOL: TypeMapping = { serialization: { fn: v => [new Fr(v ? 1n : 0n)] }, - deserialization: { fn: ([reader]) => !reader.readField().isZero(), slots: 1 }, + deserialization: { fn: ([reader]) => !reader.readField().isZero() }, + shape: ['scalar'], }; export const U32: TypeMapping = { @@ -119,13 +133,14 @@ export const U32: TypeMapping = { } return Number(value); }, - slots: 1, }, + shape: ['scalar'], }; export const BLOCK_NUMBER: TypeMapping = { serialization: { fn: v => [new Fr(v)] }, - deserialization: { fn: ([reader]) => BlockNumber(reader.readField().toNumber()), slots: 1 }, + deserialization: { fn: ([reader]) => BlockNumber(reader.readField().toNumber()) }, + shape: ['scalar'], }; /** A u8 byte: serializes to a single Fr; deserializes from a single Fr to a number in [0, 255]. */ @@ -139,21 +154,22 @@ export const BYTE: TypeMapping = { } return Number(value); }, - slots: 1, }, + shape: ['scalar'], }; // Noir passes `MessageDelivery` onchain variants here. export const DELIVERY_MODE: TypeMapping = { deserialization: { fn: readers => appTaggingSecretKindFromDeliveryMode(BYTE.deserialization!.fn(readers)), - slots: BYTE.deserialization!.slots, }, + shape: BYTE.shape, }; export const BIGINT: TypeMapping = { serialization: { fn: v => [new Fr(v)] }, - deserialization: { fn: ([reader]) => reader.readField().toBigInt(), slots: 1 }, + deserialization: { fn: ([reader]) => reader.readField().toBigInt() }, + shape: ['scalar'], }; /** Reads every field in the slot as a UTF-8 character code. */ @@ -167,61 +183,62 @@ export const STR: TypeMapping = { } return chars.join(''); }, - slots: 1, }, + shape: ['variable'], }; export const AZTEC_ADDRESS: TypeMapping = { serialization: { fn: v => [v.toField()] }, - deserialization: { fn: ([reader]) => AztecAddress.fromField(reader.readField()), slots: 1 }, + deserialization: { fn: ([reader]) => AztecAddress.fromField(reader.readField()) }, + shape: ['scalar'], }; export const BLOCK_HASH: TypeMapping = { serialization: { fn: v => [new Fr(v.toBuffer())] }, - deserialization: { fn: ([reader]) => new BlockHash(reader.readField()), slots: 1 }, + deserialization: { fn: ([reader]) => new BlockHash(reader.readField()) }, + shape: ['scalar'], }; export const FUNCTION_SELECTOR: TypeMapping = { serialization: { fn: v => [v.toField()] }, - deserialization: { fn: ([reader]) => FunctionSelector.fromField(reader.readField()), slots: 1 }, + deserialization: { fn: ([reader]) => FunctionSelector.fromField(reader.readField()) }, + shape: ['scalar'], }; export const NOTE_SELECTOR: TypeMapping = { serialization: { fn: v => [v.toField()] }, - deserialization: { fn: ([reader]) => NoteSelector.fromField(reader.readField()), slots: 1 }, + deserialization: { fn: ([reader]) => NoteSelector.fromField(reader.readField()) }, + shape: ['scalar'], }; export const TX_HASH: TypeMapping = { serialization: { fn: v => [v.hash] }, - deserialization: { fn: ([reader]) => TxHash.fromField(reader.readField()), slots: 1 }, + deserialization: { fn: ([reader]) => TxHash.fromField(reader.readField()) }, + shape: ['scalar'], }; export const TAG: TypeMapping = { serialization: { fn: v => [v.value] }, - deserialization: { fn: ([reader]) => new Tag(reader.readField()), slots: 1 }, + deserialization: { fn: ([reader]) => new Tag(reader.readField()) }, + shape: ['scalar'], }; export const POINT: TypeMapping = { serialization: { fn: p => [p.toFields()] }, - deserialization: { - fn: ([reader]) => Point.fromFields([reader.readField(), reader.readField()]), - slots: 1, - }, + deserialization: { fn: ([reader]) => Point.fromFields([reader.readField(), reader.readField()]) }, + shape: [{ len: 2 }], }; // ─── Struct Type Mappings ──────────────────────────────────────────────────── export const BLOCK_HEADER: TypeMapping = { serialization: { fn: v => v.toFields() }, - deserialization: { fn: ([reader]) => BlockHeader.fromFields(reader.readFieldArray(BLOCK_HEADER_LENGTH)), slots: 1 }, + shape: Array(BLOCK_HEADER_LENGTH).fill('scalar'), }; export const KEY_VALIDATION_REQUEST: TypeMapping = { serialization: { fn: v => v.toFields() }, - deserialization: { - fn: ([reader]) => KeyValidationRequest.fromFields(reader.readFieldArray(KEY_VALIDATION_REQUEST_LENGTH)), - slots: 1, - }, + shape: ['scalar', 'scalar'], }; export const CONTRACT_INSTANCE: TypeMapping = { @@ -236,6 +253,7 @@ export const CONTRACT_INSTANCE: TypeMapping = { ...v.publicKeys.toFields(), ], }, + shape: Array(CONTRACT_INSTANCE_LENGTH).fill('scalar'), }; export const NULLIFIER_MEMBERSHIP_WITNESS: TypeMapping = { @@ -245,6 +263,7 @@ export const NULLIFIER_MEMBERSHIP_WITNESS: TypeMapping (Array.isArray(slot) ? slot.map(s => Fr.fromString(s)) : Fr.fromString(slot as string))), }, + shape: ['scalar', 'scalar', 'scalar', 'scalar', { len: 42 }], }; export const PUBLIC_DATA_WITNESS: TypeMapping = { @@ -254,6 +273,7 @@ export const PUBLIC_DATA_WITNESS: TypeMapping = { .toNoirRepresentation() .map(slot => (Array.isArray(slot) ? slot.map(s => Fr.fromString(s)) : Fr.fromString(slot as string))), }, + shape: ['scalar', 'scalar', 'scalar', 'scalar', 'scalar', { len: 40 }], }; export const MESSAGE_LOAD_ORACLE_INPUTS: TypeMapping> = { @@ -263,20 +283,19 @@ export const MESSAGE_LOAD_ORACLE_INPUTS: TypeMapping (Array.isArray(slot) ? slot.map(s => Fr.fromString(s)) : Fr.fromString(slot as string))), }, + shape: ['scalar', { len: L1_TO_L2_MSG_TREE_HEIGHT }], // leaf index + sibling path }; export const UTILITY_CONTEXT: TypeMapping = { serialization: { - fn: (ctx: UtilityContext) => [ - ...ctx.blockHeader.toFields(), - ctx.contractAddress.toField(), - ctx.msgSender.toField(), - ], + fn: (ctx: UtilityContext) => [...ctx.blockHeader.toFields(), ctx.contractAddress.toField()], }, + shape: Array(BLOCK_HEADER_LENGTH + 1).fill('scalar'), // block header + contract address }; export const CALL_PRIVATE_RESULT: TypeMapping<{ endSideEffectCounter: Fr; returnsHash: Fr }> = { serialization: { fn: v => [[v.endSideEffectCounter, v.returnsHash]] }, + shape: [{ len: 2 }], }; export const PUBLIC_KEYS_AND_PARTIAL_ADDRESS: TypeMapping<{ @@ -286,19 +305,20 @@ export const PUBLIC_KEYS_AND_PARTIAL_ADDRESS: TypeMapping<{ serialization: { fn: v => [[...v.publicKeys.toFields(), v.partialAddress]], }, + shape: [{ len: 8 }], // a single slot of 7 public-key fields + partial address }; export const CONTRACT_CLASS_LOG_INPUT: TypeMapping = { deserialization: { fn: ([addrReader, fieldsReader, lengthReader]) => { const addr = AztecAddress.fromField(addrReader.readField()); - const fields = new ContractClassLogFields([...fieldsReader.readFieldArray(fieldsReader.remainingFields())]); + const fields = new ContractClassLogFields(fieldsReader.readFieldArray(fieldsReader.remainingFields())); const length = lengthReader.readField().toNumber(); return new ContractClassLog(addr, fields, length); }, - // ContractClassLog input occupies 3 ACVM slots: [contractAddress], [message fields...], [length]. - slots: 3, }, + // ContractClassLog input occupies 3 slots: [contractAddress], [message fields...], [length]. + shape: ['scalar', 'variable', 'scalar'], }; export const TX_EFFECT: TypeMapping = { @@ -328,6 +348,21 @@ export const TX_EFFECT: TypeMapping = { ] as (Fr | Fr[])[]; }, }, + // Mirrors the output above: revertCode, txHash, fee, then padded note hashes / nullifiers / l2-to-l1 msgs / + // public-data writes / private logs, the public-logs length, the public-logs payload, and the contract-class logs. + shape: [ + 'scalar', + 'scalar', + 'scalar', + { len: 64 }, + { len: 64 }, + { len: 8 }, + { len: 128 }, + { len: 1088 }, + 'scalar', + { len: 4096 }, + { len: 3025 }, + ], }; export const NOTE: TypeMapping = { @@ -343,57 +378,65 @@ export const NOTE: TypeMapping = { note: noteData.note, }), }, + // A packed note is the note's (variable-count) field items followed by 6 metadata scalars, emitted as one field + // output per element. Its length depends on the note, so it is described as a single variable-width run. + shape: ['variable'], }; export const PENDING_TAGGED_LOG: TypeMapping = { serialization: { fn: log => [log.toFields()] }, + shape: [{ len: 84 }], }; export const NOTE_VALIDATION_REQUEST: TypeMapping = { deserialization: { fn: ([reader]) => NoteValidationRequest.fromFields(reader), - slots: 1, }, + shape: ['variable'], }; export const EVENT_VALIDATION_REQUEST: TypeMapping = { deserialization: { fn: ([reader]) => EventValidationRequest.fromFields(reader), - slots: 1, }, + shape: ['variable'], }; export const LOG_RETRIEVAL_REQUEST: TypeMapping = { serialization: { fn: req => [req.toFields()] }, deserialization: { fn: ([reader]) => LogRetrievalRequest.fromFields(reader), - slots: 1, }, + // address, tag, source, then (isSome, value) for each of fromBlock and toBlock. + shape: [{ len: 7 }], }; export const LOG_RETRIEVAL_RESPONSE: TypeMapping = { serialization: { fn: resp => [resp.toFields()] }, + shape: [{ len: 83 }], }; export const MESSAGE_CONTEXT: TypeMapping = { serialization: { fn: mc => [mc.toFields()] }, + shape: [{ len: 67 }], }; export const PROVIDED_SECRET: TypeMapping = { deserialization: { fn: ([reader]) => ProvidedSecret.fromFields(reader), - slots: 1, }, + shape: [{ len: 2 }], }; // ─── Combinator Type Mappings ──────────────────────────────────────────────── /** `_height` is unused at runtime but lets TypeScript infer the exact `N` for `MembershipWitness`. */ -export function MEMBERSHIP_WITNESS(_height: N): TypeMapping> { +export function MEMBERSHIP_WITNESS(height: N): TypeMapping> { return { serialization: { fn: (witness: MembershipWitness) => [new Fr(witness.leafIndex), [...witness.siblingPath]], }, + shape: ['scalar', { len: height }], }; } @@ -407,15 +450,21 @@ export function ARRAY(inner: TypeMapping): TypeMapping & { kind: 'arr deserialization: inner.deserialization ? { fn: ([reader]) => { + // All elements are flattened into one slot; read them out one (fixed-width) element at a time, giving each + // its own per-slot readers reconstructed from the element's shape. + const elementWidth = fieldWidth(inner.shape); const result: T[] = []; while (!reader.isFinished()) { - result.push(inner.deserialization!.fn([reader])); + const fields = reader.readFieldArray(elementWidth); + const elementReader = splitByShape(fields, inner.shape); + result.push(inner.deserialization!.fn(elementReader)); } return result; }, - slots: 1, } : undefined, + // One slot of variable length (all elements flattened into it). + shape: [{ lenFrom: (size: { length: number }) => size.length * fieldWidth(inner.shape) }], }; } @@ -452,26 +501,34 @@ export function BOUNDED_VEC( deserialization: inner.deserialization ? { fn: ([storageReader, lengthReader]) => { - const maxLength = storageReader.remainingFields(); + // slot 0 is the padded storage, slot 1 the actual length. Parse only the first `length` elements out of + // storage; the trailing zero-padding is left untouched. + const elementWidth = fieldWidth(inner.shape); + const maxLength = storageReader.remainingFields() / elementWidth; const length = lengthReader.readField().toNumber(); const elements: T[] = []; for (let i = 0; i < length; i++) { - elements.push(inner.deserialization!.fn([storageReader])); + const fields = storageReader.readFieldArray(elementWidth); + const elementReader = splitByShape(fields, inner.shape); + elements.push(inner.deserialization!.fn(elementReader)); } // Drain the trailing zero-padding (maxLength - length unused element slots) so the storage reader is // fully consumed. storageReader.skip(storageReader.remainingFields()); return BoundedVec.from({ data: elements, maxLength }); }, - slots: 2, } : undefined, + // slot 0: variable-length storage; slot 1: the length scalar. + shape: [{ lenFrom: (size: { maxLength: number }) => size.maxLength * fieldWidth(inner.shape) }, 'scalar'], }; } /** - * Wraps an inner TypeMapping in Noir-style `Option`. Adds a discriminant slot and uses the handler-provided - * `Option.none(shape)` template to produce a correctly-sized zero-filled output for the None case. + * Wraps an inner TypeMapping in Noir-style `Option`, adding a leading discriminant slot. + * + * For the `None` case, the inner's slots must still be present on the wire as zero-padding (so `Some` and `None` have + * identical size). That padding is derived entirely from `inner.shape`. * * @example Serializing `Option.some(AztecAddress.fromField(Fr(42)))` with `OPTION(AZTEC_ADDRESS)`: * ``` @@ -479,10 +536,10 @@ export function BOUNDED_VEC( * slot 1: Fr(42) // inner value * ``` * - * @example Serializing `Option.empty(AztecAddress.ZERO)` with `OPTION(AZTEC_ADDRESS)`: + * @example Serializing `Option.none()` with `OPTION(AZTEC_ADDRESS)`: * ``` * slot 0: Fr(0) // discriminant: None - * slot 1: Fr(0) // zero-filled using shape + * slot 1: Fr(0) // zero-filled from AZTEC_ADDRESS.shape * ``` */ export function OPTION(inner: TypeMapping): TypeMapping> & { kind: 'option'; inner: TypeMapping } { @@ -491,36 +548,27 @@ export function OPTION(inner: TypeMapping): TypeMapping> & { kin inner, serialization: inner.serialization ? { - fn: opt => { - if (opt.isSome()) { - return [Fr.ONE, ...inner.serialization!.fn(opt.value)]; - } - if (opt.template === undefined) { - throw new Error( - 'Cannot serialize Option.empty() without an emptyTemplate — provide one via Option.empty(emptyTemplate)', - ); - } - const zeroSlots = inner - .serialization!.fn(opt.template) - .map(s => (Array.isArray(s) ? Array(s.length).fill(Fr.ZERO) : Fr.ZERO)); - return [Fr.ZERO, ...zeroSlots]; - }, + fn: opt => + opt.isSome() + ? [Fr.ONE, ...inner.serialization!.fn(opt.value)] + : [Fr.ZERO, ...zeroSlotsFromShape(inner.shape, opt.size)], } : undefined, deserialization: inner.deserialization ? { fn: ([discriminant, ...innerReaders]) => { if (discriminant.readField().isZero()) { - // None still carries zero-filled inner slots on the wire; drain them so the inner readers are fully - // consumed. + // None still carries the inner's zero-padded slots; consume them without parsing, since an inner that + // validates its fields would reject the zeros. innerReaders.forEach(reader => reader.skip(reader.remainingFields())); - return Option.none(undefined as unknown as T); + return Option.none(); } return Option.some(inner.deserialization!.fn(innerReaders)); }, - slots: inner.deserialization.slots + 1, } : undefined, + // A leading discriminant slot followed by the inner's slots. + shape: ['scalar', ...inner.shape], }; } @@ -535,24 +583,27 @@ export function BUFFER(bitSize: number): TypeMapping { const fields = reader.readFieldArray(reader.remainingFields()).map(f => f.toString()); return fromUintArray(fields, bitSize); }, - slots: 1, }, + shape: ['variable'], }; } export function EPHEMERAL_ARRAY(element: TypeMapping): TypeMapping> { - // An EphemeralArray param is a single slot; the per-param assert covers that slot but never sees the - // per-row readers materialized in readAll(). Assert full consumption per row here. + // EphemeralArray.readAll hands each row's flat fields in as a single reader; reconstruct the element's per-slot + // readers from its shape, deserialize, and assert the row was fully consumed so a row with trailing fields is + // rejected. const rowElement: TypeMapping | undefined = element.deserialization ? { deserialization: { - fn: readers => { + fn: ([rowReader]) => { + const fields = rowReader.readFieldArray(rowReader.remainingFields()); + const readers = splitByShape(fields, element.shape); const value = element.deserialization!.fn(readers); assertReadersConsumed(readers); return value; }, - slots: element.deserialization.slots, }, + shape: element.shape, } : undefined; return { @@ -560,7 +611,75 @@ export function EPHEMERAL_ARRAY(element: TypeMapping): TypeMapping [ea.materializeSlot(v => element.serialization!.fn(v).flat() as Fr[])] } : undefined, deserialization: rowElement - ? { fn: ([reader]) => EphemeralArray.fromSlot(reader.readField(), rowElement), slots: 1 } + ? { fn: ([reader]) => EphemeralArray.fromSlot(reader.readField(), rowElement) } : undefined, + // A single slot carrying the array's service-slot id. + shape: ['scalar'], }; } + +/** Number of InputSlots a deserializable type spans, derived from its {@link TypeMapping.shape}. */ +export function slotsOf(mapping: TypeMapping): number { + return mapping.shape.length; +} + +/** Number of fields a fully-static shape occupies. Throws on a variable-width shape, whose field count isn't known. */ +function fieldWidth(shape: SlotShape[]): number { + return shape.reduce((acc, slot) => { + if (slot === 'scalar') { + return acc + 1; + } + if (typeof slot === 'object' && 'len' in slot) { + return acc + slot.len; + } + throw new Error('Cannot compute a fixed field width for a variable-width shape'); + }, 0); +} + +/** Reconstructs a value's per-slot readers from a flat run of fields, using its shape (inverse of slot-flattening). */ +function splitByShape(fields: Fr[], shape: SlotShape[]): FieldReader[] { + const readers: FieldReader[] = []; + let cursor = 0; + shape.forEach((slot, i) => { + if (slot === 'scalar' || (typeof slot === 'object' && 'len' in slot)) { + const width = slot === 'scalar' ? 1 : slot.len; + if (cursor + width > fields.length) { + throw new Error(`Not enough fields to reconstruct shape: needed ${width}, had ${fields.length - cursor}`); + } + readers.push(new FieldReader(fields.slice(cursor, cursor + width))); + cursor += width; + } else { + // A variable slot (sized or not) takes whatever remains, so it must be last. + if (i !== shape.length - 1) { + throw new Error('A variable-width slot must be last to be reconstructed from a flat field array'); + } + readers.push(new FieldReader(fields.slice(cursor))); + cursor = fields.length; + } + }); + if (cursor !== fields.length) { + throw new Error(`Malformed flattened value: ${fields.length - cursor} unexpected trailing field(s)`); + } + return readers; +} + +/** Builds the zero-filled slots for a `None`, matching a `Some`'s wire shape (variable slots sized from `size`). */ +function zeroSlotsFromShape(shape: SlotShape[], size: unknown): (Fr | Fr[])[] { + return shape.map(slot => { + if (slot === 'scalar') { + return Fr.ZERO; + } + if (slot === 'variable') { + throw new Error('Cannot zero-fill an unsized variable-width slot'); + } + if ('len' in slot) { + return Array(slot.len).fill(Fr.ZERO); + } + if (size === undefined) { + throw new Error( + 'Serializing Option.none() over a variable-size inner needs a size, e.g. Option.none({ length: n })', + ); + } + return Array(slot.lenFrom(size)).fill(Fr.ZERO); + }); +} diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts index 7fae6481e3e4..cf691842f04d 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts @@ -177,9 +177,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP * Returns the wallet-supplied default sender for tags, or `None` if no default was provided. */ public getSenderForTags(): Promise> { - return Promise.resolve( - this.defaultSenderForTags ? Option.some(this.defaultSenderForTags) : Option.none(AztecAddress.ZERO), - ); + return Promise.resolve(this.defaultSenderForTags ? Option.some(this.defaultSenderForTags) : Option.none()); } /** @@ -199,7 +197,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP this.logger.warn(`Computing a tagging secret for invalid recipient ${recipient} - returning no secret`, { contractAddress: this.contractAddress, }); - return Option.none(Fr.ZERO); + return Option.none(); } return Option.some(extendedSecret.secret); diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts index 84a27e63724a..1e84a0c9ddf2 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts @@ -259,7 +259,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra const witness = await this.#queryWithBlockHashNotAfterAnchor(referenceBlockHash, () => this.aztecNode.getBlockHashMembershipWitness(referenceBlockHash, blockHash), ); - return witness ? Option.some(witness) : Option.none(MembershipWitness.empty(ARCHIVE_HEIGHT)); + return witness ? Option.some(witness) : Option.none(); } /** @@ -351,7 +351,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra ): Promise> { const completeAddress = await this.addressStore.getCompleteAddress(account); if (!completeAddress) { - return Option.none({ publicKeys: PublicKeys.default(), partialAddress: Fr.ZERO }); + return Option.none(); } return Option.some({ publicKeys: completeAddress.publicKeys, partialAddress: completeAddress.partialAddress }); } @@ -650,9 +650,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra this.anchorBlockHeader.getBlockNumber(), ); - const options = maybeMessageContexts.map(mc => - mc ? Option.some(mc) : Option.none(MessageContext.empty()), - ); + const options = maybeMessageContexts.map(mc => (mc ? Option.some(mc) : Option.none())); return EphemeralArray.fromValues(this.ephemeralArrayService, options); } @@ -667,7 +665,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra const receipt = await this.aztecNode.getTxReceipt(txHash, { includeTxEffect: true }); if (!receipt.isMined() || !receipt.txEffect || receipt.blockNumber > this.anchorBlockHeader.getBlockNumber()) { - return Option.none(TxEffect.empty()); + return Option.none(); } return Option.some(receipt.txEffect); @@ -692,7 +690,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra throw new Error(`Contract ${contractAddress} is not allowed to access ${this.contractAddress}'s PXE DB`); } const values = await this.capsuleService.getCapsule(contractAddress, slot, this.jobId, scope, this.capsules); - return values ? Option.some(values) : Option.none(new Array(tSize).fill(Fr.ZERO)); + return values ? Option.some(values) : Option.none({ length: tSize }); } public deleteCapsule(contractAddress: AztecAddress, slot: Fr, scope: AztecAddress): void { @@ -740,7 +738,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra const plaintext = await aes128.decryptBufferCBC(Buffer.from(ciphertext.data), iv, symKey); return Option.some(BoundedVec.from({ data: [...plaintext], maxLength: capacity })); } catch { - return Option.none(BoundedVec.empty({ maxLength: capacity })); + return Option.none({ maxLength: capacity }); } } diff --git a/yarn-project/txe/src/oracle/test-resolver/resolver.test.ts b/yarn-project/txe/src/oracle/test-resolver/resolver.test.ts index 4230904a4ac1..844b2c1d7057 100644 --- a/yarn-project/txe/src/oracle/test-resolver/resolver.test.ts +++ b/yarn-project/txe/src/oracle/test-resolver/resolver.test.ts @@ -40,7 +40,7 @@ const TEST_FIXTURES: Record = { test_single: [{ inputs: { slot: new Fr(10), addr: AztecAddress.fromNumber(1) }, output: new Fr(42) }], test_multi: [ { scenario: 'some', inputs: {}, output: Option.some(AztecAddress.fromNumber(7)) }, - { scenario: 'none', inputs: {}, output: Option.none(AztecAddress.ZERO) }, + { scenario: 'none', inputs: {}, output: Option.none() }, ], test_labeled: [ { scenario: 'some', inputs: { slot: new Fr(10) }, output: Option.some(new Fr(1)) }, diff --git a/yarn-project/txe/src/oracle/txe_oracle_registry.ts b/yarn-project/txe/src/oracle/txe_oracle_registry.ts index 3d3fc4aa098e..d17a1935fced 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_registry.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_registry.ts @@ -4,6 +4,7 @@ import { MAX_NOTE_HASHES_PER_TX, MAX_NULLIFIERS_PER_TX, MAX_PRIVATE_LOGS_PER_TX, + PRIVATE_CONTEXT_INPUTS_LENGTH, PRIVATE_LOG_SIZE_IN_FIELDS, } from '@aztec/constants'; import { Fr } from '@aztec/foundation/curves/bn254'; @@ -23,6 +24,7 @@ import { type OracleRegistryEntry, type ParamTypes, STR, + type SlotShape, type TypeMapping, U32, makeEntry, @@ -43,19 +45,20 @@ import { import type { ForeignCallArgs, ForeignCallResult } from '../utils/encoding.js'; const GAS_SETTINGS: TypeMapping = { - serialization: { fn: v => v.toFields() }, deserialization: { fn: ([reader]) => GasSettings.fromFields(reader.readFieldArray(GAS_SETTINGS_LENGTH)), - slots: 1, }, + shape: [{ len: GAS_SETTINGS_LENGTH }], }; const PRIVATE_CONTEXT_INPUTS: TypeMapping = { serialization: { fn: v => v.toFields() }, + shape: Array(PRIVATE_CONTEXT_INPUTS_LENGTH).fill('scalar'), }; const COMPLETE_ADDRESS: TypeMapping = { serialization: { fn: v => [v.address.toField(), ...v.publicKeys.toFields()] }, + shape: Array(8).fill('scalar'), // address + 7 public-key fields }; const TXE_TX_EFFECTS: TypeMapping<{ @@ -91,6 +94,17 @@ const TXE_TX_EFFECTS: TypeMapping<{ ] as (Fr | Fr[])[]; }, }, + // txHash, padded note hashes + count, padded nullifiers + count, flattened private-log storage + lengths + count. + shape: [ + 'scalar', + { len: MAX_NOTE_HASHES_PER_TX }, + 'scalar', + { len: MAX_NULLIFIERS_PER_TX }, + 'scalar', + { len: MAX_PRIVATE_LOGS_PER_TX * PRIVATE_LOG_SIZE_IN_FIELDS }, + { len: MAX_PRIVATE_LOGS_PER_TX }, + 'scalar', + ], }; const TXE_OFFCHAIN_EFFECTS: TypeMapping<{ effects: Fr[][] }> = { @@ -109,6 +123,12 @@ const TXE_OFFCHAIN_EFFECTS: TypeMapping<{ effects: Fr[][] }> = { return [rawArrayStorage, effectLengths, new Fr(effects.length)] as (Fr | Fr[])[]; }, }, + // Flattened effect storage, per-effect lengths, then the effect count. + shape: [ + { len: MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY * MAX_OFFCHAIN_EFFECT_LEN }, + { len: MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY }, + 'scalar', + ], }; const TXE_CALL_CONTEXT: TypeMapping<{ txHash: Fr; anchorBlockTimestamp: bigint }> = { @@ -118,15 +138,18 @@ const TXE_CALL_CONTEXT: TypeMapping<{ txHash: Fr; anchorBlockTimestamp: bigint } return [new Fr(isSome), txHash, new Fr(anchorBlockTimestamp)]; }, }, + shape: ['scalar', 'scalar', 'scalar'], // discriminant, txHash, anchor block timestamp }; const CONTRACT_INSTANCE_MEMBER: TypeMapping<{ member: Fr; exists: boolean }> = { serialization: { fn: ({ member, exists }) => [member, new Fr(exists)] }, + shape: ['scalar', 'scalar'], }; const EVENT_SELECTOR: TypeMapping = { serialization: { fn: v => [v.toField()] }, - deserialization: { fn: ([reader]) => EventSelector.fromField(reader.readField()), slots: 1 }, + deserialization: { fn: ([reader]) => EventSelector.fromField(reader.readField()) }, + shape: ['scalar'], }; const TXE_PRIVATE_EVENTS: TypeMapping = { @@ -144,6 +167,12 @@ const TXE_PRIVATE_EVENTS: TypeMapping = { return [rawArrayStorage, eventLengths, new Fr(events.length)] as (Fr | Fr[])[]; }, }, + // Flattened event storage, per-event lengths, then the event count. + shape: [ + { len: MAX_PRIVATE_EVENTS_PER_TXE_QUERY * MAX_PRIVATE_EVENT_LEN }, + { len: MAX_PRIVATE_EVENTS_PER_TXE_QUERY }, + 'scalar', + ], }; export const TXE_ORACLE_REGISTRY = { From aaabdfab4a79b309426dd31202146d731b4631ca Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Sat, 20 Jun 2026 09:27:58 -0300 Subject: [PATCH 2/6] refactor(pxe): tighten oracle (de)serialization slot checks --- .../oracle/oracle_registry.ts | 15 +++++++++++--- .../oracle/oracle_type_mappings.ts | 20 ++++++++++++++----- .../oracle/utility_execution_oracle.ts | 2 +- yarn-project/txe/src/rpc_translator.ts | 2 +- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts index 0f94816f4243..7801454c6055 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts @@ -107,7 +107,10 @@ export const ORACLE_REGISTRY = { aztec_utl_getUtilityContext: makeEntry({ returnType: UTILITY_CONTEXT }), aztec_utl_getKeyValidationRequest: makeEntry({ - params: [{ name: 'pkMHash', type: FIELD }], + params: [ + { name: 'pkMHash', type: FIELD }, + { name: 'keyIndex', type: FIELD }, + ], returnType: KEY_VALIDATION_REQUEST, }), @@ -544,7 +547,7 @@ export function makeEntry { + const named = resolvedParams.map(param => { if (!param.type.deserialization) { throw new Error(`Param '${param.name}' has no deserialization defined`); } @@ -559,7 +562,13 @@ export function makeEntry; + }); + // Every input slot must be modelled by a param: oracles whose Noir decl passes an extra field must declare it + // (the handler can ignore it). Otherwise an under-declared shape would silently drop a field into nothing. + if (offset !== inputs.length) { + throw new Error(`Oracle received ${inputs.length} input slot(s) but the registry models ${offset}`); + } + return named as unknown as InferDeserializedParams; }, serializeReturn(result: TReturnValue): OutputSlot[] { if (returnType?.serialization === undefined) { diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts index f8cf2b1c840d..a83decb4c2f8 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts @@ -288,9 +288,13 @@ export const MESSAGE_LOAD_ORACLE_INPUTS: TypeMapping = { serialization: { - fn: (ctx: UtilityContext) => [...ctx.blockHeader.toFields(), ctx.contractAddress.toField()], + fn: (ctx: UtilityContext) => [ + ...ctx.blockHeader.toFields(), + ctx.contractAddress.toField(), + ctx.msgSender.toField(), + ], }, - shape: Array(BLOCK_HEADER_LENGTH + 1).fill('scalar'), // block header + contract address + shape: Array(BLOCK_HEADER_LENGTH + 2).fill('scalar'), // block header + contract address + msg sender }; export const CALL_PRIVATE_RESULT: TypeMapping<{ endSideEffectCounter: Fr; returnsHash: Fr }> = { @@ -457,7 +461,9 @@ export function ARRAY(inner: TypeMapping): TypeMapping & { kind: 'arr while (!reader.isFinished()) { const fields = reader.readFieldArray(elementWidth); const elementReader = splitByShape(fields, inner.shape); - result.push(inner.deserialization!.fn(elementReader)); + const value = inner.deserialization!.fn(elementReader); + assertReadersConsumed(elementReader); + result.push(value); } return result; }, @@ -510,7 +516,9 @@ export function BOUNDED_VEC( for (let i = 0; i < length; i++) { const fields = storageReader.readFieldArray(elementWidth); const elementReader = splitByShape(fields, inner.shape); - elements.push(inner.deserialization!.fn(elementReader)); + const value = inner.deserialization!.fn(elementReader); + assertReadersConsumed(elementReader); + elements.push(value); } // Drain the trailing zero-padding (maxLength - length unused element slots) so the storage reader is // fully consumed. @@ -603,7 +611,9 @@ export function EPHEMERAL_ARRAY(element: TypeMapping): TypeMapping { + public async getKeyValidationRequest(pkMHash: Fr, _keyIndex: Fr): Promise { let hasAccess = false; for (let i = 0; i < this.scopes.length && !hasAccess; i++) { if (await this.keyStore.accountHasKey(this.scopes[i], pkMHash)) { diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index ae56166087a7..f99c66c8dfc5 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -439,7 +439,7 @@ export class RPCTranslator { return callTxeHandler({ oracle: 'aztec_utl_getKeyValidationRequest', inputs, - handler: ([pkMHash]) => this.handlerAsUtility().getKeyValidationRequest(pkMHash), + handler: ([pkMHash, keyIndex]) => this.handlerAsUtility().getKeyValidationRequest(pkMHash, keyIndex), }); } From 43d153eaf1391eb7b3e7893044c0ae8070ea0413 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Sat, 20 Jun 2026 11:19:05 -0300 Subject: [PATCH 3/6] test(pxe): unit-test oracle type mapping (de)serialization --- .../oracle/oracle_type_mappings.test.ts | 326 ++++++++++++++++++ .../oracle/oracle_type_mappings.ts | 1 - 2 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts new file mode 100644 index 000000000000..453e3076a077 --- /dev/null +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts @@ -0,0 +1,326 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import { Point } from '@aztec/foundation/curves/grumpkin'; +import { FieldReader } from '@aztec/foundation/serialize'; +import { MembershipWitness } from '@aztec/foundation/trees'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { AppTaggingSecretKind, Tag } from '@aztec/stdlib/logs'; + +import { EphemeralArrayService } from '../ephemeral_array_service.js'; +import { BoundedVec } from '../noir-structs/bounded_vec.js'; +import { LogRetrievalRequest } from '../noir-structs/log_retrieval_request.js'; +import { Option } from '../noir-structs/option.js'; +import { + ARRAY, + AZTEC_ADDRESS, + BOUNDED_VEC, + BYTE, + DELIVERY_MODE, + EPHEMERAL_ARRAY, + FIELD, + LOG_RETRIEVAL_REQUEST, + MEMBERSHIP_WITNESS, + OPTION, + POINT, + type SlotShape, + type TypeMapping, + U32, + makeEntry, + slotsOf, +} from './oracle_registry.js'; + +/** + * Tests for the oracle type mappings: how the PXE encodes values to, and decodes them from, the flat field arrays that + * Noir oracles exchange over the ACVM foreign-call interface. + * + * A mapping's wire form is a list of *slots*. Each slot is either a single field (a scalar) or a run of fields (e.g. an + * array's contents). `serialization.fn` produces the slots; `deserialization.fn` reads them back, one `FieldReader` per + * slot. A mapping's `shape` declares each slot's width up front, so the reader knows where one slot ends and the next + * begins. + * + * Most tests *round-trip*: serialize a value, deserialize the result, and assert it comes back unchanged (see the + * `roundTrip` helper at the bottom). The rest pin a specific encoding, or check that malformed wire input is rejected. + */ +describe('oracle type mappings', () => { + describe('FIELD', () => { + it('serializes to its declared shape', () => { + expect(shapeOf(FIELD.serialization!.fn(Fr.random()))).toEqual(FIELD.shape); + }); + + it('reads one input slot', () => { + expect(slotsOf(FIELD)).toBe(1); + }); + }); + + describe('U32', () => { + it('deserializes a value in range', () => { + expect(deserialize(U32, new Fr(42))).toBe(42); + }); + + it('deserializes the maximum value', () => { + expect(deserialize(U32, new Fr(0xffffffffn))).toBe(0xffffffff); + }); + + it('rejects a value above the maximum', () => { + expect(() => deserialize(U32, new Fr(0x100000000n))).toThrow('U32 overflow'); + }); + }); + + describe('BYTE', () => { + it('deserializes a value in range', () => { + expect(deserialize(BYTE, new Fr(0))).toBe(0); + }); + + it('deserializes the maximum value', () => { + expect(deserialize(BYTE, new Fr(255))).toBe(255); + }); + + it('rejects a value above the maximum', () => { + expect(() => deserialize(BYTE, new Fr(256))).toThrow('BYTE overflow'); + }); + }); + + // DELIVERY_MODE maps Noir's on-chain MessageDelivery variants (2 = unconstrained, 3 = constrained) to a tagging kind, + // and rejects any other value. + describe('DELIVERY_MODE', () => { + it('deserializes unconstrained delivery as unconstrained tagging', () => { + expect(deserialize(DELIVERY_MODE, new Fr(2))).toBe(AppTaggingSecretKind.UNCONSTRAINED); + }); + + it('deserializes constrained delivery as constrained tagging', () => { + expect(deserialize(DELIVERY_MODE, new Fr(3))).toBe(AppTaggingSecretKind.CONSTRAINED); + }); + + it('rejects an invalid value', () => { + expect(() => deserialize(DELIVERY_MODE, new Fr(1))).toThrow('Unrecognized delivery mode for tagging'); + }); + }); + + describe('AZTEC_ADDRESS', () => { + it('serializes to its declared shape', async () => { + expect(shapeOf(AZTEC_ADDRESS.serialization!.fn(await AztecAddress.random()))).toEqual(AZTEC_ADDRESS.shape); + }); + }); + + // An Option serializes to a leading discriminant slot followed by the inner's slots. + describe('OPTION', () => { + it('round-trips a Some', () => { + const value = Fr.random(); + const out = roundTrip(OPTION(FIELD), Option.some(value)); + expect(out.isSome()).toBe(true); + expect(out.value).toEqual(value); + }); + + it('round-trips a None, skipping the inner instead of parsing it', () => { + // A None still occupies the inner's slots as zero padding. REJECTS_ZERO_FIELD_TYPE throws on a zero, so a None + // whose inner were parsed would throw here; the test passing proves the inner was skipped. + const out = roundTrip(OPTION(REJECTS_ZERO_FIELD_TYPE), Option.none()); + expect(out.isNone()).toBe(true); + }); + + it('serializes a fixed None as zero-padding', () => { + // A None occupies the same slots as a Some, zero-filled. Here: [discriminant, the FIELD slot zeroed]. + expect(OPTION(FIELD).serialization!.fn(Option.none())).toEqual([Fr.ZERO, Fr.ZERO]); + }); + + it('serializes a variable None from its size descriptor', () => { + // A variable inner (array, bounded vec) has no fixed width, so the None's size comes from the descriptor passed + // to Option.none: { length } for an array, { maxLength } for a bounded vec. + expect(OPTION(ARRAY(FIELD)).serialization!.fn(Option.none({ length: 3 }))).toEqual([ + Fr.ZERO, + [Fr.ZERO, Fr.ZERO, Fr.ZERO], + ]); + expect(OPTION(BOUNDED_VEC(BYTE)).serialization!.fn(Option.none>({ maxLength: 2 }))).toEqual([ + Fr.ZERO, + [Fr.ZERO, Fr.ZERO], + Fr.ZERO, + ]); + }); + + it('rejects a variable None with no size descriptor', () => { + expect(() => OPTION(ARRAY(FIELD)).serialization!.fn(Option.none())).toThrow('needs a size'); + }); + + it('reads two input slots', () => { + expect(slotsOf(OPTION(AZTEC_ADDRESS))).toBe(2); // discriminant + inner + }); + }); + + // A BoundedVec serializes to two slots: the element storage (padded to maxLength), then the actual length. + // E.g. BoundedVec.from({ data: [0x41, 0x42], maxLength: 4 }) → storage [0x41, 0x42, 0, 0], length 2. + describe('BOUNDED_VEC', () => { + it('round-trips a full vec', () => { + const data = [Fr.random(), Fr.random()]; + const out = roundTrip(BOUNDED_VEC(FIELD), BoundedVec.from({ data, maxLength: 2 })); + expect(out.data).toEqual(data); + expect(out.maxLength).toBe(2); + }); + + it('round-trips a partially-full vec', () => { + const data = [Fr.random()]; + const out = roundTrip(BOUNDED_VEC(FIELD), BoundedVec.from({ data, maxLength: 4 })); + expect(out.data).toEqual(data); + expect(out.maxLength).toBe(4); + }); + + it('round-trips an empty vec', () => { + const out = roundTrip(BOUNDED_VEC(FIELD), BoundedVec.from({ data: [], maxLength: 3 })); + expect(out.data).toEqual([]); + expect(out.maxLength).toBe(3); + }); + + it('round-trips a vec of multi-field elements', async () => { + // A point spans two fields, so its elementSize is 2. + const data = [await Point.random(), await Point.random()]; + const out = roundTrip(BOUNDED_VEC(POINT), BoundedVec.from({ data, maxLength: 3, elementSize: 2 })); + expect(out.data).toEqual(data); + expect(out.maxLength).toBe(3); + }); + + it('round-trips a vec of multi-slot elements', () => { + const present = Fr.random(); + const data = [Option.some(present), Option.none()]; + const vec = BoundedVec.from({ data, maxLength: 3, elementSize: 2 }); + const out = roundTrip(BOUNDED_VEC(OPTION(FIELD)), vec); + expect(out.data.map(o => o.isSome())).toEqual([true, false]); + expect(out.data[0].value).toEqual(present); + expect(out.maxLength).toBe(3); + }); + + it('fully consumes a partially-full vec as an oracle param', () => { + // The registry rejects a param whose slots aren't fully read. A partially-full vec leaves zero padding in its + // storage slot, so this checks the deserializer drains that padding instead of tripping the consumption check. + const entry = makeEntry({ params: [{ name: 'ciphertext', type: BOUNDED_VEC(BYTE) }] }); + const inputs = toInputSlots(BOUNDED_VEC(BYTE).serialization!.fn(BoundedVec.from({ data: [1, 2], maxLength: 4 }))); + const [{ value }] = entry.deserializeParams(inputs); + expect(value.data).toEqual([1, 2]); + expect(value.maxLength).toBe(4); + }); + + it('rejects an element that under-reads its slot', () => { + const storage = new FieldReader([new Fr(1), new Fr(2)]); + const length = new FieldReader([new Fr(1)]); + expect(() => BOUNDED_VEC(UNDER_READS_SLOT_TYPE).deserialization!.fn([storage, length])).toThrow( + 'unexpected trailing field(s)', + ); + }); + + it('reads two input slots', () => { + expect(slotsOf(BOUNDED_VEC(FIELD))).toBe(2); // storage + length + }); + }); + + // An array serializes to a single slot holding every element's fields back to back. + describe('ARRAY', () => { + it('round-trips a mix of Some and None elements', () => { + const data = [Option.some(new Fr(7)), Option.none(), Option.some(new Fr(9))]; + const out = roundTrip(ARRAY(OPTION(FIELD)), data); + expect(out.map(o => o.isSome())).toEqual([true, false, true]); + expect(out[0].value).toEqual(new Fr(7)); + expect(out[2].value).toEqual(new Fr(9)); + }); + + it('rejects an element that under-reads its slot', () => { + expect(() => ARRAY(UNDER_READS_SLOT_TYPE).deserialization!.fn([new FieldReader([new Fr(1), new Fr(2)])])).toThrow( + 'unexpected trailing field(s)', + ); + }); + }); + + // An EphemeralArray param is a handle to a list of rows, each row being one flat slot of fields. + describe('EPHEMERAL_ARRAY', () => { + it('deserializes single-field rows', () => { + const a = Fr.random(); + const b = Fr.random(); + expect(readEphemeralArray(FIELD, [[a], [b]])).toEqual([a, b]); + }); + + it('rejects a row with too few fields', () => { + expect(() => readEphemeralArray(FIELD, [[]])).toThrow('Not enough fields to reconstruct shape'); + }); + + it('rejects a row with trailing fields', () => { + expect(() => readEphemeralArray(FIELD, [[Fr.random(), Fr.random()]])).toThrow('unexpected trailing field(s)'); + }); + + it('rejects a multi-field row with trailing fields', async () => { + const row = new LogRetrievalRequest(await AztecAddress.random(), new Tag(Fr.random())).toFields(); + expect(() => readEphemeralArray(LOG_RETRIEVAL_REQUEST, [[...row, Fr.random()]])).toThrow( + 'unexpected trailing field(s)', + ); + }); + + it('deserializes multi-slot rows with a None', () => { + // A row is one flat slot, but an Option element spans two; the deserializer rebuilds the element's slots from the + // row's fields. The None keeps that reconstruction honest (OPTION owns the skip behavior). + const present = Fr.random(); + const rows = [Option.some(present), Option.none()].map(opt => toRow(OPTION(FIELD), opt)); + const result = readEphemeralArray(OPTION(FIELD), rows); + expect(result[0].isSome()).toBe(true); + expect(result[0].value).toEqual(present); + expect(result[1].isNone()).toBe(true); + }); + + /** Reads an input-mode EphemeralArray backed by `rows`, as the registry does when deserializing a param. */ + function readEphemeralArray(element: TypeMapping, rows: Fr[][]): T[] { + const service = new EphemeralArrayService(); + const slot = service.newArray(rows); + const array = EPHEMERAL_ARRAY(element).deserialization!.fn([new FieldReader([slot])]); + return array.readAll(service); + } + + /** Serializes a value to a single flat field row, the way an EphemeralArray stores each element. */ + function toRow(element: TypeMapping, value: T): Fr[] { + return element.serialization!.fn(value).flatMap(slot => (Array.isArray(slot) ? slot : [slot])); + } + }); + + describe('MEMBERSHIP_WITNESS', () => { + it('serializes to its declared shape', () => { + const witness = MEMBERSHIP_WITNESS(4); + expect(shapeOf(witness.serialization!.fn(MembershipWitness.random(4)))).toEqual(witness.shape); + }); + }); +}); + +/** A field mapping that throws on a zero, proving a None skips its zero-padded inner instead of parsing it. */ +const REJECTS_ZERO_FIELD_TYPE: TypeMapping = { + serialization: { fn: v => [v] }, + deserialization: { + fn: ([reader]) => { + const field = reader.readField(); + if (field.isZero()) { + throw new Error('REJECTS_ZERO_FIELD_TYPE read a zero value'); + } + return field; + }, + }, + shape: ['scalar'], +}; + +/** A mapping whose shape claims two fields but whose fn reads only one, leaving a trailing field unread. */ +const UNDER_READS_SLOT_TYPE: TypeMapping = { + deserialization: { fn: ([reader]) => reader.readField() }, + shape: [{ len: 2 }], +}; + +/** Round-trips a value through a bidirectional mapping: serialize to wire slots, then deserialize back. */ +function roundTrip(mapping: TypeMapping, value: T): T { + const slots = mapping.serialization!.fn(value); + const readers = slots.map(slot => new FieldReader(Array.isArray(slot) ? slot : [slot])); + return mapping.deserialization!.fn(readers); +} + +/** Converts serialized wire slots into the hex `InputSlot[]` form the registry's `deserializeParams` expects. */ +function toInputSlots(slots: (Fr | Fr[])[]): string[][] { + return slots.map(slot => (Array.isArray(slot) ? slot : [slot]).map(f => f.toString())); +} + +/** Deserializes a single-slot scalar mapping (U32, BYTE, ...) from one field. */ +function deserialize(mapping: TypeMapping, value: Fr): T { + return mapping.deserialization!.fn([new FieldReader([value])]); +} + +/** The wire shape of an already-serialized value, for comparing against a type's declared `shape`. */ +function shapeOf(slots: (Fr | Fr[])[]): SlotShape[] { + return slots.map(slot => (Array.isArray(slot) ? { len: slot.length } : 'scalar')); +} diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts index a83decb4c2f8..7a277d059164 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts @@ -434,7 +434,6 @@ export const PROVIDED_SECRET: TypeMapping = { // ─── Combinator Type Mappings ──────────────────────────────────────────────── -/** `_height` is unused at runtime but lets TypeScript infer the exact `N` for `MembershipWitness`. */ export function MEMBERSHIP_WITNESS(height: N): TypeMapping> { return { serialization: { From 00a81e9aa2f4e6814d70bcec24a8bb118c756e17 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Sat, 20 Jun 2026 11:41:29 -0300 Subject: [PATCH 4/6] chore(pxe): update oracle interface hash --- yarn-project/pxe/src/oracle_version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index 9b9a096ea9f8..813a9281c170 100644 --- a/yarn-project/pxe/src/oracle_version.ts +++ b/yarn-project/pxe/src/oracle_version.ts @@ -19,4 +19,4 @@ export const ORACLE_VERSION_MINOR = 0; /// - 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 = 'f5cd3321b32371186f30dfd11b246946fb425cadffb8e6564b897d5184e43fe9'; +export const ORACLE_INTERFACE_HASH = '04e973e69ac73ab958cec7908abcfc40fdac5615a10c890808928c962d91c652'; From d2d7d76711d88c5ad9bf907462eaa21b3193ed70 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Mon, 22 Jun 2026 08:47:39 -0300 Subject: [PATCH 5/6] docs(pxe): clarify oracle (de)serialization wording --- .../src/contract_function_simulator/noir-structs/option.ts | 2 +- .../src/contract_function_simulator/oracle/oracle_registry.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/option.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/option.ts index dca1b9f64826..4d9ca790e899 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/option.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/option.ts @@ -28,7 +28,7 @@ export class Option { /** * Construct an absent Option. * - * When serialized back to ACVM, the `None` case must produce the same number of fields as `Some`. For an inner whose + * When serialized back to ACVM, the `None` case must produce the same number of fields as `Some`. For an inner type whose * wire size varies per call site (`BoundedVec`, an array), pass a `size` descriptor so the inner type's shape can * resolve how many zero fields to emit; fixed-size inners take no argument. * diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts index 7801454c6055..f279eafa1697 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts @@ -563,10 +563,10 @@ export function makeEntry; }, From d68da31308b84097d623b86d556d18f65460f959 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Mon, 22 Jun 2026 12:45:38 -0300 Subject: [PATCH 6/6] docs(pxe): document unused _keyIndex oracle param --- .../oracle/utility_execution_oracle.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts index fe80011f9749..07e0a45b4a99 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts @@ -201,6 +201,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra /** * Retrieve keys associated with a specific master public key and app address. * @param pkMHash - The master public key hash. + * @param _keyIndex - Sent by the Noir oracle caller but unused here; kept to match the oracle signature. * @returns A Promise that resolves to nullifier keys. * @throws If the keys are not registered in the key store. * @throws If scopes are defined and the account is not in the scopes.