From 63c64460d74f74ab7e8ce20ba5b0a7d37923a2c7 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Fri, 27 Feb 2026 11:23:53 -0300 Subject: [PATCH 1/7] fix(pxe): backward-compatible deserialization of validation requests --- .../event_validation_request.test.ts | 82 +++++++++++++ .../noir-structs/event_validation_request.ts | 15 ++- .../noir-structs/log_retrieval_response.ts | 5 +- .../note_validation_request.test.ts | 108 +++++++++++++----- .../noir-structs/note_validation_request.ts | 21 ++-- 5 files changed, 189 insertions(+), 42 deletions(-) diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts index 63816730790f..556e6fe9b375 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts @@ -37,4 +37,86 @@ describe('EventValidationRequest', () => { expect(request.txHash).toEqual(TxHash.fromBigInt(7n)); expect(request.recipient).toEqual(AztecAddress.fromBigInt(8n)); }); + + // Older aztec-nr versions used a larger BoundedVec capacity. We need to support this for backward compatibility. + it('accepts BoundedVec with capacity larger than MAX_EVENT_CONTENT_LEN if content length is valid', () => { + const serialized = [ + 1, // contract_address + 2, // event_type_id + 3, // randomness + 10, // serialized_event[0] + 11, // serialized_event[1] + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, // padding to 11 storage fields (old BoundedVec capacity) + 2, // bounded_vec_len + 6, // event_commitment + 7, // tx_hash + 8, // recipient + ].map(n => new Fr(n)); + + const request = EventValidationRequest.fromFields(serialized); + + expect(request.contractAddress).toEqual(AztecAddress.fromBigInt(1n)); + expect(request.serializedEvent).toEqual([new Fr(10), new Fr(11)]); + expect(request.eventCommitment).toEqual(new Fr(6)); + }); + + it('throws if eventLen exceeds MAX_EVENT_CONTENT_LEN', () => { + const serialized = [ + 1, // contract_address + 2, // event_type_id + 3, // randomness + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, // 11 storage fields + 11, // bounded_vec_len = 11, exceeds MAX_EVENT_CONTENT_LEN = 10 + 6, // event_commitment + 7, // tx_hash + 8, // recipient + ].map(n => new Fr(n)); + + expect(() => EventValidationRequest.fromFields(serialized)).toThrow(/exceeds MAX_EVENT_CONTENT_LEN/); + }); + + // Lowering MAX_EVENT_CONTENT_LEN would break deserialization of already-deployed contracts that use the current max. + it('accepts eventLen = MAX_EVENT_CONTENT_LEN', () => { + const serialized = [ + 1, // contract_address + 2, // event_type_id + 3, // randomness + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, // 10 storage fields + 10, // bounded_vec_len = 10 = MAX_EVENT_CONTENT_LEN + 6, // event_commitment + 7, // tx_hash + 8, // recipient + ].map(n => new Fr(n)); + + const request = EventValidationRequest.fromFields(serialized); + + expect(request.serializedEvent).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19].map(n => new Fr(n))); + }); }); diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts index 8a33dd551923..9998372ee168 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts @@ -4,8 +4,10 @@ import { EventSelector } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { TxHash } from '@aztec/stdlib/tx'; -// TODO(#14617): should we compute this from constants? This value is aztec-nr specific. -const MAX_EVENT_SERIALIZED_LEN = 10; +// Event content is serialized as a BoundedVec: `storage[capacity] ++ len`. The storage capacity may differ across +// aztec-nr versions (e.g. old contracts used 11, current ones use 10), so we infer it dynamically from the total field +// count. This constant is only used to validate the *content length* (the BoundedVec `.len()`), not the storage size. +const MAX_EVENT_CONTENT_LEN = 10; /** * Intermediate struct used to perform batch event validation by PXE. The `utilityValidateAndStoreEnqueuedNotesAndEvents` oracle @@ -30,8 +32,15 @@ export class EventValidationRequest { const randomness = reader.readField(); - const eventStorage = reader.readFieldArray(MAX_EVENT_SERIALIZED_LEN); + // Infer BoundedVec storage size from total field count for backward compat with older aztec-nr versions. + const FOOTER_FIELDS = 4; // 1 BoundedVec len + eventCommitment + txHash + recipient + const arraySize = reader.remainingFields() - FOOTER_FIELDS; + + const eventStorage = reader.readFieldArray(arraySize); const eventLen = reader.readField().toNumber(); + if (eventLen > MAX_EVENT_CONTENT_LEN) { + throw new Error(`Event content length ${eventLen} exceeds MAX_EVENT_CONTENT_LEN ${MAX_EVENT_CONTENT_LEN}.`); + } const serializedEvent = eventStorage.slice(0, eventLen); const eventCommitment = reader.readField(); diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/log_retrieval_response.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/log_retrieval_response.ts index 020270e8821a..baa4b3c58e39 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/log_retrieval_response.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/log_retrieval_response.ts @@ -3,10 +3,7 @@ import { range } from '@aztec/foundation/array'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { TxHash } from '@aztec/stdlib/tx'; -import { MAX_NOTE_PACKED_LEN } from './note_validation_request.js'; - -const MAX_PUBLIC_LOG_LEN_FOR_NOTE_COMPLETION = MAX_NOTE_PACKED_LEN; -const MAX_LOG_CONTENT_LEN = Math.max(MAX_PUBLIC_LOG_LEN_FOR_NOTE_COMPLETION, PRIVATE_LOG_CIPHERTEXT_LEN); +const MAX_LOG_CONTENT_LEN = PRIVATE_LOG_CIPHERTEXT_LEN; /** * Intermediate struct used to perform batch log retrieval by PXE. The `utilityBulkRetrieveLogs` oracle stores values of this diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts index 4ac64de1d016..32364d4819bd 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts @@ -19,7 +19,7 @@ describe('NoteValidationRequest', () => { '0x0000000000000000000000000000000000000000000000000000000000000000', '0x0000000000000000000000000000000000000000000000000000000000000000', '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', // content end (MAX_NOTE_PACKED_LEN = 8) + '0x0000000000000000000000000000000000000000000000000000000000000000', // content end (MAX_NOTE_CONTENT_LEN = 8) '0x0000000000000000000000000000000000000000000000000000000000000002', // content length '0x0000000000000000000000000000000000000000000000000000000000000006', // note hash '0x0000000000000000000000000000000000000000000000000000000000000007', // nullifier @@ -41,32 +41,88 @@ describe('NoteValidationRequest', () => { expect(request.recipient).toEqual(AztecAddress.fromBigInt(9n)); }); - it('throws if fed more fields than expected', () => { + // Older aztec-nr versions used a larger BoundedVec capacity. We need to support this for backward compatibility. + it('accepts BoundedVec with capacity larger than MAX_NOTE_CONTENT_LEN if content length is valid', () => { const serialized = [ - '0x0000000000000000000000000000000000000000000000000000000000000001', // contract address - '0x0000000000000000000000000000000000000000000000000000000000000032', // owner - '0x0000000000000000000000000000000000000000000000000000000000000002', // storage slot - '0X000000000000000000000000000000000000000000000000000000000000002a', // randomness - '0x0000000000000000000000000000000000000000000000000000000000000003', // note nonce - '0x0000000000000000000000000000000000000000000000000000000000000004', // content begin: note content 1 - '0x0000000000000000000000000000000000000000000000000000000000000005', // note content 2 - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', // content end (MAX_NOTE_PACKED_LEN = 8) - '0x0000000000000000000000000000000000000000000000000000000000000000', // extra field beyond MAX_NOTE_PACKED_LEN, this is a malformed serialization - '0x0000000000000000000000000000000000000000000000000000000000000002', // content length - '0x0000000000000000000000000000000000000000000000000000000000000006', // note hash - '0x0000000000000000000000000000000000000000000000000000000000000007', // nullifier - '0x0000000000000000000000000000000000000000000000000000000000000008', // tx hash - '0x0000000000000000000000000000000000000000000000000000000000000009', // recipient - ].map(Fr.fromHexString); + 1, // contract address + 2, // owner + 3, // storage slot + 4, // randomness + 5, // note nonce + 10, // content[0] + 11, // content[1] + 0, + 0, + 0, + 0, + 0, + 0, + 0, // padding to 9 storage fields (old BoundedVec capacity) + 2, // content length + 6, // note hash + 7, // nullifier + 8, // tx hash + 9, // recipient + ].map(n => new Fr(n)); + + const request = NoteValidationRequest.fromFields(serialized); + + expect(request.contractAddress).toEqual(AztecAddress.fromBigInt(1n)); + expect(request.content).toEqual([new Fr(10), new Fr(11)]); + expect(request.noteHash).toEqual(new Fr(6)); + }); + + it('throws if contentLen exceeds MAX_NOTE_CONTENT_LEN', () => { + const serialized = [ + 1, // contract address + 2, // owner + 3, // storage slot + 4, // randomness + 5, // note nonce + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, // 9 storage fields + 9, // content length = 9, exceeds MAX_NOTE_CONTENT_LEN = 8 + 6, // note hash + 7, // nullifier + 8, // tx hash + 9, // recipient + ].map(n => new Fr(n)); + + expect(() => NoteValidationRequest.fromFields(serialized)).toThrow(/exceeds MAX_NOTE_CONTENT_LEN/); + }); + + // Lowering MAX_NOTE_CONTENT_LEN would break deserialization of already-deployed contracts that use the current max. + it('accepts contentLen = MAX_NOTE_CONTENT_LEN', () => { + const serialized = [ + 1, // contract address + 2, // owner + 3, // storage slot + 4, // randomness + 5, // note nonce + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, // 8 storage fields + 8, // content length = 8 = MAX_NOTE_CONTENT_LEN + 6, // note hash + 7, // nullifier + 8, // tx hash + 9, // recipient + ].map(n => new Fr(n)); + + const request = NoteValidationRequest.fromFields(serialized); - expect(() => NoteValidationRequest.fromFields(serialized)).toThrow( - /Error converting array of fields to NoteValidationRequest/, - ); + expect(request.content).toEqual([10, 11, 12, 13, 14, 15, 16, 17].map(n => new Fr(n))); }); }); diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts index 02ebba99e96e..60c0c7670242 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts @@ -3,8 +3,10 @@ import { FieldReader } from '@aztec/foundation/serialize'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { TxHash } from '@aztec/stdlib/tx'; -// TODO(#14617): should we compute this from constants? This value is aztec-nr specific. -export const MAX_NOTE_PACKED_LEN = 8; +// Note content is serialized as a BoundedVec: `storage[capacity] ++ len`. The storage capacity may differ across +// aztec-nr versions (e.g. old contracts used 9, current ones use 8), so we infer it dynamically from the total field +// count. This constant is only used to validate the *content length* (the BoundedVec `.len()`), not the storage size. +const MAX_NOTE_CONTENT_LEN = 8; /** * Intermediate struct used to perform batch note validation by PXE. The `utilityValidateAndStoreEnqueuedNotesAndEvents` oracle @@ -33,8 +35,15 @@ export class NoteValidationRequest { const randomness = reader.readField(); const noteNonce = reader.readField(); - const contentStorage = reader.readFieldArray(MAX_NOTE_PACKED_LEN); + // Infer BoundedVec storage size from total field count for backward compat with older aztec-nr versions. + const FOOTER_FIELDS = 5; // 1 BoundedVec len + noteHash + nullifier + txHash + recipient + const arraySize = reader.remainingFields() - FOOTER_FIELDS; + + const contentStorage = reader.readFieldArray(arraySize); const contentLen = reader.readField().toNumber(); + if (contentLen > MAX_NOTE_CONTENT_LEN) { + throw new Error(`Note content length ${contentLen} exceeds MAX_NOTE_CONTENT_LEN ${MAX_NOTE_CONTENT_LEN}.`); + } const content = contentStorage.slice(0, contentLen); const noteHash = reader.readField(); @@ -42,12 +51,6 @@ export class NoteValidationRequest { const txHash = TxHash.fromField(reader.readField()); const recipient = AztecAddress.fromField(reader.readField()); - if (reader.remainingFields() !== 0) { - throw new Error( - `Error converting array of fields to NoteValidationRequest. Hint: check that MAX_NOTE_PACKED_LEN is consistent with private_notes::MAX_NOTE_PACKED_LEN in Aztec-nr.`, - ); - } - return new NoteValidationRequest( contractAddress, owner, From f5bc681a2e2271aca942a3fec78aaae3e277363a Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Fri, 27 Feb 2026 12:00:11 -0300 Subject: [PATCH 2/7] fix(pxe): validate arraySize and contentLen against BoundedVec storage capacity --- .../event_validation_request.test.ts | 28 ++++++++++++++++ .../noir-structs/event_validation_request.ts | 8 +++++ .../note_validation_request.test.ts | 33 +++++++++++++++++++ .../noir-structs/note_validation_request.ts | 8 +++++ 4 files changed, 77 insertions(+) diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts index 556e6fe9b375..0a4fa319d02b 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts @@ -119,4 +119,32 @@ describe('EventValidationRequest', () => { expect(request.serializedEvent).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19].map(n => new Fr(n))); }); + + it('throws on malformed input that is too short', () => { + const serialized = [ + 1, // contract_address + 2, // event_type_id + 3, // randomness + // missing BoundedVec storage + footer + ].map(n => new Fr(n)); + + expect(() => EventValidationRequest.fromFields(serialized)).toThrow(/Malformed EventValidationRequest/); + }); + + it('throws if eventLen exceeds BoundedVec storage capacity', () => { + const serialized = [ + 1, // contract_address + 2, // event_type_id + 3, // randomness + 10, + 11, + 12, // 3 storage fields + 4, // bounded_vec_len = 4, exceeds storage capacity of 3 + 6, // event_commitment + 7, // tx_hash + 8, // recipient + ].map(n => new Fr(n)); + + expect(() => EventValidationRequest.fromFields(serialized)).toThrow(/exceeds BoundedVec storage capacity/); + }); }); diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts index 9998372ee168..fecf9f7ac6dc 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts @@ -35,12 +35,20 @@ export class EventValidationRequest { // Infer BoundedVec storage size from total field count for backward compat with older aztec-nr versions. const FOOTER_FIELDS = 4; // 1 BoundedVec len + eventCommitment + txHash + recipient const arraySize = reader.remainingFields() - FOOTER_FIELDS; + if (arraySize < 0) { + throw new Error( + `Malformed EventValidationRequest: expected at least ${FOOTER_FIELDS} fields after header, got ${reader.remainingFields()}.`, + ); + } const eventStorage = reader.readFieldArray(arraySize); const eventLen = reader.readField().toNumber(); if (eventLen > MAX_EVENT_CONTENT_LEN) { throw new Error(`Event content length ${eventLen} exceeds MAX_EVENT_CONTENT_LEN ${MAX_EVENT_CONTENT_LEN}.`); } + if (eventLen > arraySize) { + throw new Error(`Event content length ${eventLen} exceeds BoundedVec storage capacity ${arraySize}.`); + } const serializedEvent = eventStorage.slice(0, eventLen); const eventCommitment = reader.readField(); diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts index 32364d4819bd..7c1e8e1e3eb4 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts @@ -125,4 +125,37 @@ describe('NoteValidationRequest', () => { expect(request.content).toEqual([10, 11, 12, 13, 14, 15, 16, 17].map(n => new Fr(n))); }); + + it('throws on malformed input that is too short', () => { + const serialized = [ + 1, // contract address + 2, // owner + 3, // storage slot + 4, // randomness + 5, // note nonce + // missing BoundedVec storage + footer + ].map(n => new Fr(n)); + + expect(() => NoteValidationRequest.fromFields(serialized)).toThrow(/Malformed NoteValidationRequest/); + }); + + it('throws if contentLen exceeds BoundedVec storage capacity', () => { + const serialized = [ + 1, // contract address + 2, // owner + 3, // storage slot + 4, // randomness + 5, // note nonce + 10, + 11, + 12, // 3 storage fields + 4, // content length = 4, exceeds storage capacity of 3 + 6, // note hash + 7, // nullifier + 8, // tx hash + 9, // recipient + ].map(n => new Fr(n)); + + expect(() => NoteValidationRequest.fromFields(serialized)).toThrow(/exceeds BoundedVec storage capacity/); + }); }); diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts index 60c0c7670242..92aa9916eb27 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts @@ -38,12 +38,20 @@ export class NoteValidationRequest { // Infer BoundedVec storage size from total field count for backward compat with older aztec-nr versions. const FOOTER_FIELDS = 5; // 1 BoundedVec len + noteHash + nullifier + txHash + recipient const arraySize = reader.remainingFields() - FOOTER_FIELDS; + if (arraySize < 0) { + throw new Error( + `Malformed NoteValidationRequest: expected at least ${FOOTER_FIELDS} fields after header, got ${reader.remainingFields()}.`, + ); + } const contentStorage = reader.readFieldArray(arraySize); const contentLen = reader.readField().toNumber(); if (contentLen > MAX_NOTE_CONTENT_LEN) { throw new Error(`Note content length ${contentLen} exceeds MAX_NOTE_CONTENT_LEN ${MAX_NOTE_CONTENT_LEN}.`); } + if (contentLen > arraySize) { + throw new Error(`Note content length ${contentLen} exceeds BoundedVec storage capacity ${arraySize}.`); + } const content = contentStorage.slice(0, contentLen); const noteHash = reader.readField(); From e49fcc0aa5cb8336caca87c771ac5360ddf35bb7 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Fri, 27 Feb 2026 12:03:29 -0300 Subject: [PATCH 3/7] chore(pxe): use numeric literals in note validation request test --- .../note_validation_request.test.ts | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts index 7c1e8e1e3eb4..60f0c07001c4 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts @@ -7,25 +7,25 @@ import { NoteValidationRequest } from './note_validation_request.js'; describe('NoteValidationRequest', () => { it('output of Noir serialization deserializes as expected', () => { const serialized = [ - '0x0000000000000000000000000000000000000000000000000000000000000001', // contract address - '0x0000000000000000000000000000000000000000000000000000000000000032', // owner - '0x0000000000000000000000000000000000000000000000000000000000000002', // storage slot - '0x000000000000000000000000000000000000000000000000000000000000002a', // randomness - '0x0000000000000000000000000000000000000000000000000000000000000003', // note nonce - '0x0000000000000000000000000000000000000000000000000000000000000004', // content begin: note content 1 - '0x0000000000000000000000000000000000000000000000000000000000000005', // note content 2 - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000000000000000000000000000', // content end (MAX_NOTE_CONTENT_LEN = 8) - '0x0000000000000000000000000000000000000000000000000000000000000002', // content length - '0x0000000000000000000000000000000000000000000000000000000000000006', // note hash - '0x0000000000000000000000000000000000000000000000000000000000000007', // nullifier - '0x0000000000000000000000000000000000000000000000000000000000000008', // tx hash - '0x0000000000000000000000000000000000000000000000000000000000000009', // recipient - ].map(Fr.fromHexString); + 1, // contract address + 50, // owner + 2, // storage slot + 42, // randomness + 3, // note nonce + 4, // content[0] + 5, // content[1] + 0, + 0, + 0, + 0, + 0, + 0, // content end (8 storage fields) + 2, // content length + 6, // note hash + 7, // nullifier + 8, // tx hash + 9, // recipient + ].map(n => new Fr(n)); const request = NoteValidationRequest.fromFields(serialized); From 2eb70c2f88556500d461bf0b2f09ca909795db89 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Mon, 2 Mar 2026 16:57:08 -0300 Subject: [PATCH 4/7] fix(pxe): hybrid BoundedVec capacity -- explicit when available, default for old contracts --- .../aztec/src/messages/processing/mod.nr | 22 +++ .../event_validation_request.test.ts | 119 +++++----------- .../noir-structs/event_validation_request.ts | 32 ++--- .../note_validation_request.test.ts | 133 +++++------------- .../noir-structs/note_validation_request.ts | 32 ++--- .../oracle/utility_execution_oracle.ts | 26 +++- 6 files changed, 138 insertions(+), 226 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr index 3f7fa63a0291..556650ab9f10 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr @@ -36,6 +36,10 @@ global EVENT_VALIDATION_REQUESTS_ARRAY_BASE_SLOT: Field = sha256_to_field( "AZTEC_NR::EVENT_VALIDATION_REQUESTS_ARRAY_BASE_SLOT".as_bytes(), ); +global NOTE_BOUNDED_VEC_CAPACITY_SLOT: Field = sha256_to_field("AZTEC_NR::NOTE_BOUNDED_VEC_CAPACITY_SLOT".as_bytes()); + +global EVENT_BOUNDED_VEC_CAPACITY_SLOT: Field = sha256_to_field("AZTEC_NR::EVENT_BOUNDED_VEC_CAPACITY_SLOT".as_bytes()); + global LOG_RETRIEVAL_REQUESTS_ARRAY_BASE_SLOT: Field = sha256_to_field( "AZTEC_NR::LOG_RETRIEVAL_REQUESTS_ARRAY_BASE_SLOT".as_bytes(), ); @@ -88,6 +92,15 @@ pub unconstrained fn enqueue_note_for_validation( tx_hash: Field, recipient: AztecAddress, ) { + // Store BoundedVec capacity so PXE knows the serialization layout. Contracts that don't store + // this will cause PXE to fall back to a default capacity. + // TODO(F-380): remove once all contracts store capacity explicitly. + oracle::capsules::store( + contract_address, + NOTE_BOUNDED_VEC_CAPACITY_SLOT, + [MAX_NOTE_PACKED_LEN as Field], + ); + // We store requests in a `CapsuleArray`, which PXE will later read from and deserialize into its version of the // Noir `NoteValidationRequest` CapsuleArray::at(contract_address, NOTE_VALIDATION_REQUESTS_ARRAY_BASE_SLOT).push( @@ -127,6 +140,15 @@ pub unconstrained fn enqueue_event_for_validation( tx_hash: Field, recipient: AztecAddress, ) { + // Store BoundedVec capacity so PXE knows the serialization layout. Contracts that don't store + // this will cause PXE to fall back to a default capacity. + // TODO(F-380): remove once all contracts store capacity explicitly. + oracle::capsules::store( + contract_address, + EVENT_BOUNDED_VEC_CAPACITY_SLOT, + [MAX_EVENT_SERIALIZED_LEN as Field], + ); + // We store requests in a `CapsuleArray`, which PXE will later read from and deserialize into its version of the // Noir `EventValidationRequest` CapsuleArray::at(contract_address, EVENT_VALIDATION_REQUESTS_ARRAY_BASE_SLOT).push( diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts index 0a4fa319d02b..9cb0ab16d09a 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts @@ -6,21 +6,23 @@ import { TxHash } from '@aztec/stdlib/tx'; import { EventValidationRequest } from './event_validation_request.js'; describe('EventValidationRequest', () => { - it('output of Noir serialization deserializes as expected', () => { + it('deserializes with default capacity when no arraySize is given', () => { + // 11 storage fields = default BoundedVec capacity (default) const serialized = [ 1, // contract_address 2, // event_type_id 3, // randomness - 4, // serialized_event[0] - 5, // serialized_event[1] - 0, // serialized_event padding start + 4, + 5, 0, 0, 0, 0, 0, 0, - 0, // serialized_event padding end + 0, + 0, + 0, // 11 storage fields 2, // bounded_vec_len 6, // event_commitment 7, // tx_hash @@ -38,14 +40,14 @@ describe('EventValidationRequest', () => { expect(request.recipient).toEqual(AztecAddress.fromBigInt(8n)); }); - // Older aztec-nr versions used a larger BoundedVec capacity. We need to support this for backward compatibility. - it('accepts BoundedVec with capacity larger than MAX_EVENT_CONTENT_LEN if content length is valid', () => { + it('deserializes with explicit arraySize matching current capacity', () => { + // 10 storage fields = current BoundedVec capacity const serialized = [ 1, // contract_address 2, // event_type_id 3, // randomness - 10, // serialized_event[0] - 11, // serialized_event[1] + 4, + 5, 0, 0, 0, @@ -53,98 +55,43 @@ describe('EventValidationRequest', () => { 0, 0, 0, - 0, - 0, // padding to 11 storage fields (old BoundedVec capacity) + 0, // 10 storage fields 2, // bounded_vec_len 6, // event_commitment 7, // tx_hash 8, // recipient ].map(n => new Fr(n)); - const request = EventValidationRequest.fromFields(serialized); + const request = EventValidationRequest.fromFields(serialized, 10); expect(request.contractAddress).toEqual(AztecAddress.fromBigInt(1n)); - expect(request.serializedEvent).toEqual([new Fr(10), new Fr(11)]); + expect(request.serializedEvent).toEqual([new Fr(4), new Fr(5)]); expect(request.eventCommitment).toEqual(new Fr(6)); }); - it('throws if eventLen exceeds MAX_EVENT_CONTENT_LEN', () => { - const serialized = [ - 1, // contract_address - 2, // event_type_id - 3, // randomness - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, // 11 storage fields - 11, // bounded_vec_len = 11, exceeds MAX_EVENT_CONTENT_LEN = 10 - 6, // event_commitment - 7, // tx_hash - 8, // recipient - ].map(n => new Fr(n)); - - expect(() => EventValidationRequest.fromFields(serialized)).toThrow(/exceeds MAX_EVENT_CONTENT_LEN/); - }); - - // Lowering MAX_EVENT_CONTENT_LEN would break deserialization of already-deployed contracts that use the current max. - it('accepts eventLen = MAX_EVENT_CONTENT_LEN', () => { - const serialized = [ - 1, // contract_address - 2, // event_type_id - 3, // randomness - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, // 10 storage fields - 10, // bounded_vec_len = 10 = MAX_EVENT_CONTENT_LEN - 6, // event_commitment - 7, // tx_hash - 8, // recipient - ].map(n => new Fr(n)); - - const request = EventValidationRequest.fromFields(serialized); - - expect(request.serializedEvent).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19].map(n => new Fr(n))); - }); - - it('throws on malformed input that is too short', () => { - const serialized = [ - 1, // contract_address - 2, // event_type_id - 3, // randomness - // missing BoundedVec storage + footer - ].map(n => new Fr(n)); - - expect(() => EventValidationRequest.fromFields(serialized)).toThrow(/Malformed EventValidationRequest/); - }); - - it('throws if eventLen exceeds BoundedVec storage capacity', () => { + it('throws if arraySize does not match actual field count (reader not exhausted)', () => { + // Data has 11 storage fields but we claim arraySize=10, leaving 1 unconsumed field const serialized = [ - 1, // contract_address - 2, // event_type_id - 3, // randomness + 1, + 2, + 3, // header 10, 11, - 12, // 3 storage fields - 4, // bounded_vec_len = 4, exceeds storage capacity of 3 - 6, // event_commitment - 7, // tx_hash - 8, // recipient + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, // 11 storage fields + 2, // bounded_vec_len + 6, + 7, + 8, // footer ].map(n => new Fr(n)); - expect(() => EventValidationRequest.fromFields(serialized)).toThrow(/exceeds BoundedVec storage capacity/); + expect(() => EventValidationRequest.fromFields(serialized, 10)).toThrow(/did not consume all fields/); }); }); diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts index fecf9f7ac6dc..3ee9054f1bba 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts @@ -4,10 +4,9 @@ import { EventSelector } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { TxHash } from '@aztec/stdlib/tx'; -// Event content is serialized as a BoundedVec: `storage[capacity] ++ len`. The storage capacity may differ across -// aztec-nr versions (e.g. old contracts used 11, current ones use 10), so we infer it dynamically from the total field -// count. This constant is only used to validate the *content length* (the BoundedVec `.len()`), not the storage size. -const MAX_EVENT_CONTENT_LEN = 10; +// Default BoundedVec storage capacity for contracts that don't explicitly store their capacity. +// TODO(F-380): remove once all contracts store capacity explicitly. +const DEFAULT_EVENT_BOUNDED_VEC_CAPACITY = 11; /** * Intermediate struct used to perform batch event validation by PXE. The `utilityValidateAndStoreEnqueuedNotesAndEvents` oracle @@ -24,7 +23,7 @@ export class EventValidationRequest { public recipient: AztecAddress, ) {} - static fromFields(fields: Fr[] | FieldReader): EventValidationRequest { + static fromFields(fields: Fr[], capacity: number = DEFAULT_EVENT_BOUNDED_VEC_CAPACITY): EventValidationRequest { const reader = FieldReader.asReader(fields); const contractAddress = AztecAddress.fromField(reader.readField()); @@ -32,29 +31,20 @@ export class EventValidationRequest { const randomness = reader.readField(); - // Infer BoundedVec storage size from total field count for backward compat with older aztec-nr versions. - const FOOTER_FIELDS = 4; // 1 BoundedVec len + eventCommitment + txHash + recipient - const arraySize = reader.remainingFields() - FOOTER_FIELDS; - if (arraySize < 0) { - throw new Error( - `Malformed EventValidationRequest: expected at least ${FOOTER_FIELDS} fields after header, got ${reader.remainingFields()}.`, - ); - } - - const eventStorage = reader.readFieldArray(arraySize); + const eventStorage = reader.readFieldArray(capacity); const eventLen = reader.readField().toNumber(); - if (eventLen > MAX_EVENT_CONTENT_LEN) { - throw new Error(`Event content length ${eventLen} exceeds MAX_EVENT_CONTENT_LEN ${MAX_EVENT_CONTENT_LEN}.`); - } - if (eventLen > arraySize) { - throw new Error(`Event content length ${eventLen} exceeds BoundedVec storage capacity ${arraySize}.`); - } const serializedEvent = eventStorage.slice(0, eventLen); const eventCommitment = reader.readField(); const txHash = TxHash.fromField(reader.readField()); const recipient = AztecAddress.fromField(reader.readField()); + if (reader.remainingFields() !== 0) { + throw new Error( + `EventValidationRequest deserialization did not consume all fields: ${reader.remainingFields()} remaining (capacity=${capacity}).`, + ); + } + return new EventValidationRequest( contractAddress, eventTypeId, diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts index 60f0c07001c4..ec48d36a254e 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts @@ -5,21 +5,23 @@ import { TxHash } from '@aztec/stdlib/tx'; import { NoteValidationRequest } from './note_validation_request.js'; describe('NoteValidationRequest', () => { - it('output of Noir serialization deserializes as expected', () => { + it('deserializes with default capacity when no arraySize is given', () => { + // 9 storage fields = old BoundedVec capacity (default) const serialized = [ 1, // contract address 50, // owner 2, // storage slot 42, // randomness 3, // note nonce - 4, // content[0] - 5, // content[1] + 4, + 5, 0, 0, 0, 0, 0, - 0, // content end (8 storage fields) + 0, + 0, // 9 storage fields 2, // content length 6, // note hash 7, // nullifier @@ -41,23 +43,22 @@ describe('NoteValidationRequest', () => { expect(request.recipient).toEqual(AztecAddress.fromBigInt(9n)); }); - // Older aztec-nr versions used a larger BoundedVec capacity. We need to support this for backward compatibility. - it('accepts BoundedVec with capacity larger than MAX_NOTE_CONTENT_LEN if content length is valid', () => { + it('deserializes with explicit arraySize matching current capacity', () => { + // 8 storage fields = current BoundedVec capacity const serialized = [ 1, // contract address - 2, // owner - 3, // storage slot - 4, // randomness - 5, // note nonce - 10, // content[0] - 11, // content[1] - 0, + 50, // owner + 2, // storage slot + 42, // randomness + 3, // note nonce + 4, + 5, 0, 0, 0, 0, 0, - 0, // padding to 9 storage fields (old BoundedVec capacity) + 0, // 8 storage fields 2, // content length 6, // note hash 7, // nullifier @@ -65,97 +66,37 @@ describe('NoteValidationRequest', () => { 9, // recipient ].map(n => new Fr(n)); - const request = NoteValidationRequest.fromFields(serialized); + const request = NoteValidationRequest.fromFields(serialized, 8); expect(request.contractAddress).toEqual(AztecAddress.fromBigInt(1n)); - expect(request.content).toEqual([new Fr(10), new Fr(11)]); + expect(request.content).toEqual([new Fr(4), new Fr(5)]); expect(request.noteHash).toEqual(new Fr(6)); }); - it('throws if contentLen exceeds MAX_NOTE_CONTENT_LEN', () => { - const serialized = [ - 1, // contract address - 2, // owner - 3, // storage slot - 4, // randomness - 5, // note nonce - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, // 9 storage fields - 9, // content length = 9, exceeds MAX_NOTE_CONTENT_LEN = 8 - 6, // note hash - 7, // nullifier - 8, // tx hash - 9, // recipient - ].map(n => new Fr(n)); - - expect(() => NoteValidationRequest.fromFields(serialized)).toThrow(/exceeds MAX_NOTE_CONTENT_LEN/); - }); - - // Lowering MAX_NOTE_CONTENT_LEN would break deserialization of already-deployed contracts that use the current max. - it('accepts contentLen = MAX_NOTE_CONTENT_LEN', () => { - const serialized = [ - 1, // contract address - 2, // owner - 3, // storage slot - 4, // randomness - 5, // note nonce - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, // 8 storage fields - 8, // content length = 8 = MAX_NOTE_CONTENT_LEN - 6, // note hash - 7, // nullifier - 8, // tx hash - 9, // recipient - ].map(n => new Fr(n)); - - const request = NoteValidationRequest.fromFields(serialized); - - expect(request.content).toEqual([10, 11, 12, 13, 14, 15, 16, 17].map(n => new Fr(n))); - }); - - it('throws on malformed input that is too short', () => { - const serialized = [ - 1, // contract address - 2, // owner - 3, // storage slot - 4, // randomness - 5, // note nonce - // missing BoundedVec storage + footer - ].map(n => new Fr(n)); - - expect(() => NoteValidationRequest.fromFields(serialized)).toThrow(/Malformed NoteValidationRequest/); - }); - - it('throws if contentLen exceeds BoundedVec storage capacity', () => { + it('throws if arraySize does not match actual field count (reader not exhausted)', () => { + // Data has 9 storage fields but we claim arraySize=8, leaving 1 unconsumed field const serialized = [ - 1, // contract address - 2, // owner - 3, // storage slot - 4, // randomness - 5, // note nonce + 1, + 2, + 3, + 4, + 5, // header 10, 11, - 12, // 3 storage fields - 4, // content length = 4, exceeds storage capacity of 3 - 6, // note hash - 7, // nullifier - 8, // tx hash - 9, // recipient + 0, + 0, + 0, + 0, + 0, + 0, + 0, // 9 storage fields + 2, // content length + 6, + 7, + 8, + 9, // footer ].map(n => new Fr(n)); - expect(() => NoteValidationRequest.fromFields(serialized)).toThrow(/exceeds BoundedVec storage capacity/); + expect(() => NoteValidationRequest.fromFields(serialized, 8)).toThrow(/did not consume all fields/); }); }); diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts index 92aa9916eb27..f26b6c17a5b9 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts @@ -3,10 +3,9 @@ import { FieldReader } from '@aztec/foundation/serialize'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { TxHash } from '@aztec/stdlib/tx'; -// Note content is serialized as a BoundedVec: `storage[capacity] ++ len`. The storage capacity may differ across -// aztec-nr versions (e.g. old contracts used 9, current ones use 8), so we infer it dynamically from the total field -// count. This constant is only used to validate the *content length* (the BoundedVec `.len()`), not the storage size. -const MAX_NOTE_CONTENT_LEN = 8; +// Default BoundedVec storage capacity for contracts that don't explicitly store their capacity. +// TODO(F-380): remove once all contracts store capacity explicitly. +const DEFAULT_NOTE_BOUNDED_VEC_CAPACITY = 9; /** * Intermediate struct used to perform batch note validation by PXE. The `utilityValidateAndStoreEnqueuedNotesAndEvents` oracle @@ -26,7 +25,7 @@ export class NoteValidationRequest { public recipient: AztecAddress, ) {} - static fromFields(fields: Fr[] | FieldReader): NoteValidationRequest { + static fromFields(fields: Fr[], capacity: number = DEFAULT_NOTE_BOUNDED_VEC_CAPACITY): NoteValidationRequest { const reader = FieldReader.asReader(fields); const contractAddress = AztecAddress.fromField(reader.readField()); @@ -35,23 +34,8 @@ export class NoteValidationRequest { const randomness = reader.readField(); const noteNonce = reader.readField(); - // Infer BoundedVec storage size from total field count for backward compat with older aztec-nr versions. - const FOOTER_FIELDS = 5; // 1 BoundedVec len + noteHash + nullifier + txHash + recipient - const arraySize = reader.remainingFields() - FOOTER_FIELDS; - if (arraySize < 0) { - throw new Error( - `Malformed NoteValidationRequest: expected at least ${FOOTER_FIELDS} fields after header, got ${reader.remainingFields()}.`, - ); - } - - const contentStorage = reader.readFieldArray(arraySize); + const contentStorage = reader.readFieldArray(capacity); const contentLen = reader.readField().toNumber(); - if (contentLen > MAX_NOTE_CONTENT_LEN) { - throw new Error(`Note content length ${contentLen} exceeds MAX_NOTE_CONTENT_LEN ${MAX_NOTE_CONTENT_LEN}.`); - } - if (contentLen > arraySize) { - throw new Error(`Note content length ${contentLen} exceeds BoundedVec storage capacity ${arraySize}.`); - } const content = contentStorage.slice(0, contentLen); const noteHash = reader.readField(); @@ -59,6 +43,12 @@ export class NoteValidationRequest { const txHash = TxHash.fromField(reader.readField()); const recipient = AztecAddress.fromField(reader.readField()); + if (reader.remainingFields() !== 0) { + throw new Error( + `NoteValidationRequest deserialization did not consume all fields: ${reader.remainingFields()} remaining (capacity=${capacity}).`, + ); + } + return new NoteValidationRequest( contractAddress, owner, 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 f957d44326a8..cb3238ecac81 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 @@ -1,6 +1,7 @@ import type { ARCHIVE_HEIGHT, NOTE_HASH_TREE_HEIGHT } from '@aztec/constants'; import type { BlockNumber } from '@aztec/foundation/branded-types'; import { Aes128 } from '@aztec/foundation/crypto/aes128'; +import { sha256ToField } from '@aztec/foundation/crypto/sha256'; import { Fr } from '@aztec/foundation/curves/bn254'; import { Point } from '@aztec/foundation/curves/grumpkin'; import { LogLevels, type Logger, createLogger } from '@aztec/foundation/log'; @@ -42,6 +43,10 @@ import { pickNotes } from '../pick_notes.js'; import type { IMiscOracle, IUtilityExecutionOracle, NoteData } from './interfaces.js'; import { MessageLoadOracleInputs } from './message_load_oracle_inputs.js'; +// Capsule slots where aztec-nr stores BoundedVec capacities (must match Noir globals in messages/processing/mod.nr). +const NOTE_BOUNDED_VEC_CAPACITY_SLOT = sha256ToField([Buffer.from('AZTEC_NR::NOTE_BOUNDED_VEC_CAPACITY_SLOT')]); +const EVENT_BOUNDED_VEC_CAPACITY_SLOT = sha256ToField([Buffer.from('AZTEC_NR::EVENT_BOUNDED_VEC_CAPACITY_SLOT')]); + /** Args for UtilityExecutionOracle constructor. */ export type UtilityExecutionOracleArgs = { contractAddress: AztecAddress; @@ -458,15 +463,32 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra throw new Error(`Got a note validation request from ${contractAddress}, expected ${this.contractAddress}`); } + // Read BoundedVec capacities from the capsule store. Contracts that don't store these explicitly will + // have null here, so fromFields will fall back to default capacities. + // TODO(F-380): make capacity required once all contracts store it explicitly. + const noteCapacityCapsule = await this.capsuleStore.loadCapsule( + contractAddress, + NOTE_BOUNDED_VEC_CAPACITY_SLOT, + this.jobId, + ); + const noteCapacity = noteCapacityCapsule?.[0]?.toNumber(); + + const eventCapacityCapsule = await this.capsuleStore.loadCapsule( + contractAddress, + EVENT_BOUNDED_VEC_CAPACITY_SLOT, + this.jobId, + ); + const eventCapacity = eventCapacityCapsule?.[0]?.toNumber(); + // We read all note and event validation requests and process them all concurrently. This makes the process much // faster as we don't need to wait for the network round-trip. const noteValidationRequests = ( await this.capsuleStore.readCapsuleArray(contractAddress, noteValidationRequestsArrayBaseSlot, this.jobId) - ).map(NoteValidationRequest.fromFields); + ).map(fields => NoteValidationRequest.fromFields(fields, noteCapacity)); const eventValidationRequests = ( await this.capsuleStore.readCapsuleArray(contractAddress, eventValidationRequestsArrayBaseSlot, this.jobId) - ).map(EventValidationRequest.fromFields); + ).map(fields => EventValidationRequest.fromFields(fields, eventCapacity)); const noteService = new NoteService(this.noteStore, this.aztecNode, this.anchorBlockHeader, this.jobId); const noteStorePromises = noteValidationRequests.map(request => From f6b8e7588b6b8717f3af7d34363df184348e55c8 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Wed, 4 Mar 2026 10:12:58 -0300 Subject: [PATCH 5/7] fix(pxe): deduplicate capacity capsule writes and clean up after use --- .../aztec/src/messages/processing/mod.nr | 31 ++++++++----------- .../oracle/utility_execution_oracle.ts | 2 ++ 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr index 556650ab9f10..e69ede53427e 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr @@ -92,15 +92,6 @@ pub unconstrained fn enqueue_note_for_validation( tx_hash: Field, recipient: AztecAddress, ) { - // Store BoundedVec capacity so PXE knows the serialization layout. Contracts that don't store - // this will cause PXE to fall back to a default capacity. - // TODO(F-380): remove once all contracts store capacity explicitly. - oracle::capsules::store( - contract_address, - NOTE_BOUNDED_VEC_CAPACITY_SLOT, - [MAX_NOTE_PACKED_LEN as Field], - ); - // We store requests in a `CapsuleArray`, which PXE will later read from and deserialize into its version of the // Noir `NoteValidationRequest` CapsuleArray::at(contract_address, NOTE_VALIDATION_REQUESTS_ARRAY_BASE_SLOT).push( @@ -140,15 +131,6 @@ pub unconstrained fn enqueue_event_for_validation( tx_hash: Field, recipient: AztecAddress, ) { - // Store BoundedVec capacity so PXE knows the serialization layout. Contracts that don't store - // this will cause PXE to fall back to a default capacity. - // TODO(F-380): remove once all contracts store capacity explicitly. - oracle::capsules::store( - contract_address, - EVENT_BOUNDED_VEC_CAPACITY_SLOT, - [MAX_EVENT_SERIALIZED_LEN as Field], - ); - // We store requests in a `CapsuleArray`, which PXE will later read from and deserialize into its version of the // Noir `EventValidationRequest` CapsuleArray::at(contract_address, EVENT_VALIDATION_REQUESTS_ARRAY_BASE_SLOT).push( @@ -172,6 +154,19 @@ pub unconstrained fn enqueue_event_for_validation( /// /// This automatically clears both validation request queues, so no further work needs to be done by the caller. pub unconstrained fn validate_and_store_enqueued_notes_and_events(contract_address: AztecAddress) { + // Store BoundedVec capacities once so PXE knows the serialization layout. Contracts that don't store these + // will cause PXE to fall back to default capacities. + oracle::capsules::store( + contract_address, + NOTE_BOUNDED_VEC_CAPACITY_SLOT, + [MAX_NOTE_PACKED_LEN as Field], + ); + oracle::capsules::store( + contract_address, + EVENT_BOUNDED_VEC_CAPACITY_SLOT, + [MAX_EVENT_SERIALIZED_LEN as Field], + ); + oracle::message_processing::validate_and_store_enqueued_notes_and_events( contract_address, NOTE_VALIDATION_REQUESTS_ARRAY_BASE_SLOT, 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 cb3238ecac81..8618c0b21a6b 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 @@ -522,6 +522,8 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra await Promise.all([...noteStorePromises, ...eventStorePromises]); // Requests are cleared once we're done. + this.capsuleStore.deleteCapsule(contractAddress, NOTE_BOUNDED_VEC_CAPACITY_SLOT, this.jobId); + this.capsuleStore.deleteCapsule(contractAddress, EVENT_BOUNDED_VEC_CAPACITY_SLOT, this.jobId); await this.capsuleStore.setCapsuleArray(contractAddress, noteValidationRequestsArrayBaseSlot, [], this.jobId); await this.capsuleStore.setCapsuleArray(contractAddress, eventValidationRequestsArrayBaseSlot, [], this.jobId); } From e4ca118c10689043a64fc808714b03a0db1e6da9 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Wed, 4 Mar 2026 11:00:52 -0300 Subject: [PATCH 6/7] test(pxe): merge note and event validation tests into combined tests --- .../aztec/src/messages/processing/mod.nr | 4 +- .../noir-structs/event_validation_request.ts | 2 +- .../noir-structs/note_validation_request.ts | 2 +- .../oracle/utility_execution.test.ts | 187 +++++++++++++++++- .../oracle/utility_execution_oracle.ts | 6 +- 5 files changed, 192 insertions(+), 9 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr index e69ede53427e..8b23babf1814 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr @@ -154,8 +154,8 @@ pub unconstrained fn enqueue_event_for_validation( /// /// This automatically clears both validation request queues, so no further work needs to be done by the caller. pub unconstrained fn validate_and_store_enqueued_notes_and_events(contract_address: AztecAddress) { - // Store BoundedVec capacities once so PXE knows the serialization layout. Contracts that don't store these - // will cause PXE to fall back to default capacities. + // Store BoundedVec capacities so PXE knows the serialization layout. Contracts that don't store these will + // cause PXE to fall back to default capacities. oracle::capsules::store( contract_address, NOTE_BOUNDED_VEC_CAPACITY_SLOT, diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts index 3ee9054f1bba..6cd0cc6ef187 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.ts @@ -6,7 +6,7 @@ import { TxHash } from '@aztec/stdlib/tx'; // Default BoundedVec storage capacity for contracts that don't explicitly store their capacity. // TODO(F-380): remove once all contracts store capacity explicitly. -const DEFAULT_EVENT_BOUNDED_VEC_CAPACITY = 11; +export const DEFAULT_EVENT_BOUNDED_VEC_CAPACITY = 11; /** * Intermediate struct used to perform batch event validation by PXE. The `utilityValidateAndStoreEnqueuedNotesAndEvents` oracle diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts index f26b6c17a5b9..b797e42d946e 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.ts @@ -5,7 +5,7 @@ import { TxHash } from '@aztec/stdlib/tx'; // Default BoundedVec storage capacity for contracts that don't explicitly store their capacity. // TODO(F-380): remove once all contracts store capacity explicitly. -const DEFAULT_NOTE_BOUNDED_VEC_CAPACITY = 9; +export const DEFAULT_NOTE_BOUNDED_VEC_CAPACITY = 9; /** * Intermediate struct used to perform batch note validation by PXE. The `utilityValidateAndStoreEnqueuedNotesAndEvents` oracle diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts index fd6cb7ef4a2a..7fb86e7a4939 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts @@ -4,15 +4,16 @@ import { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin'; import type { KeyStore } from '@aztec/key-store'; import { StatefulTestContractArtifact } from '@aztec/noir-test-contracts.js/StatefulTest'; import { WASMSimulator } from '@aztec/simulator/client'; -import { FunctionCall, FunctionSelector, FunctionType, encodeArguments } from '@aztec/stdlib/abi'; +import { EventSelector, FunctionCall, FunctionSelector, FunctionType, encodeArguments } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { BlockHash } from '@aztec/stdlib/block'; import { CompleteAddress, type ContractInstanceWithAddress } from '@aztec/stdlib/contract'; +import { computeUniqueNoteHash, siloNoteHash, siloNullifier } from '@aztec/stdlib/hash'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { deriveKeys } from '@aztec/stdlib/keys'; import { Note, NoteDao } from '@aztec/stdlib/note'; import { makeL2Tips } from '@aztec/stdlib/testing'; -import { BlockHeader, GlobalVariables, TxHash } from '@aztec/stdlib/tx'; +import { BlockHeader, GlobalVariables, TxEffect, TxHash } from '@aztec/stdlib/tx'; import { mock } from 'jest-mock-extended'; import type { _MockProxy } from 'jest-mock-extended/lib/Mock.js'; @@ -27,7 +28,13 @@ import type { RecipientTaggingStore } from '../../storage/tagging_store/recipien import type { SenderAddressBookStore } from '../../storage/tagging_store/sender_address_book_store.js'; import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagging_store.js'; import { ContractFunctionSimulator } from '../contract_function_simulator.js'; -import { UtilityExecutionOracle } from './utility_execution_oracle.js'; +import { DEFAULT_EVENT_BOUNDED_VEC_CAPACITY } from '../noir-structs/event_validation_request.js'; +import { DEFAULT_NOTE_BOUNDED_VEC_CAPACITY } from '../noir-structs/note_validation_request.js'; +import { + EVENT_BOUNDED_VEC_CAPACITY_SLOT, + NOTE_BOUNDED_VEC_CAPACITY_SLOT, + UtilityExecutionOracle, +} from './utility_execution_oracle.js'; describe('Utility Execution test suite', () => { const simulator = new WASMSimulator(); @@ -231,5 +238,179 @@ describe('Utility Execution test suite', () => { ); }); }); + + describe('utilityValidateAndStoreEnqueuedNotesAndEvents', () => { + const noteSlot = new Fr(100); + const eventSlot = new Fr(200); + + const noteContractAddress = AztecAddress.fromField(new Fr(1)); + const noteHash = new Fr(6); + const noteNonce = new Fr(3); + const noteTxHash = TxHash.fromField(new Fr(8)); + + const eventContractAddress = AztecAddress.fromField(new Fr(10)); + const eventCommitment = new Fr(60); + const eventTxHash = TxHash.fromField(new Fr(80)); + + const noteContentValues = [4, 5]; + const eventContentValues = [40, 50]; + + it('deserializes and stores notes and events when capacity is explicit in capsule', async () => { + mockCapsuleStore({ noteCapacity: 8, eventCapacity: 8 }); + await mockNode(); + + await utilityExecutionOracle.utilityValidateAndStoreEnqueuedNotesAndEvents( + contractAddress, + noteSlot, + eventSlot, + ); + + assertNoteStoredCorrectly(); + assertEventStoredCorrectly(); + assertCapsulesCleanedUp(); + }); + + it('deserializes and stores notes and events using default capacity when capsule has none', async () => { + mockCapsuleStore(); + await mockNode(); + + await utilityExecutionOracle.utilityValidateAndStoreEnqueuedNotesAndEvents( + contractAddress, + noteSlot, + eventSlot, + ); + + assertNoteStoredCorrectly(); + assertEventStoredCorrectly(); + assertCapsulesCleanedUp(); + }); + + // --- Helpers --- + + // Wire format: contract_address, owner, storage_slot, randomness, nonce, + // ...noteContent (padded to capacity), contentLen, noteHash, nullifier, txHash, recipient + function buildSerializedNote(capacity: number): Fr[] { + const padding = new Array(capacity - noteContentValues.length).fill(0); + return [1, 50, 2, 42, 3, ...noteContentValues, ...padding, noteContentValues.length, 6, 7, 8, 9].map( + v => new Fr(v), + ); + } + + // Wire format: contract_address, event_type_id, randomness, + // ...eventContent (padded to capacity), contentLen, event_commitment, tx_hash, recipient + function buildSerializedEvent(capacity: number): Fr[] { + const padding = new Array(capacity - eventContentValues.length).fill(0); + return [10, 20, 30, ...eventContentValues, ...padding, eventContentValues.length, 60, 80, 90].map( + v => new Fr(v), + ); + } + + /** Populates the capsule store with one serialized note and one event request, using explicit or default capacity. */ + function mockCapsuleStore(opts: { noteCapacity?: number; eventCapacity?: number } = {}) { + const noteSerializedCapacity = opts.noteCapacity ?? DEFAULT_NOTE_BOUNDED_VEC_CAPACITY; + const eventSerializedCapacity = opts.eventCapacity ?? DEFAULT_EVENT_BOUNDED_VEC_CAPACITY; + + const capsules = new Map(); + if (opts.noteCapacity !== undefined) { + capsules.set(`${contractAddress.toString()}:${NOTE_BOUNDED_VEC_CAPACITY_SLOT.toString()}`, [ + new Fr(opts.noteCapacity), + ]); + } + if (opts.eventCapacity !== undefined) { + capsules.set(`${contractAddress.toString()}:${EVENT_BOUNDED_VEC_CAPACITY_SLOT.toString()}`, [ + new Fr(opts.eventCapacity), + ]); + } + + const capsuleArrays = new Map(); + capsuleArrays.set(`${contractAddress.toString()}:${noteSlot.toString()}`, [ + buildSerializedNote(noteSerializedCapacity), + ]); + capsuleArrays.set(`${contractAddress.toString()}:${eventSlot.toString()}`, [ + buildSerializedEvent(eventSerializedCapacity), + ]); + + capsuleStore.loadCapsule.mockImplementation((address, slot) => + Promise.resolve(capsules.get(`${address.toString()}:${slot.toString()}`) ?? null), + ); + capsuleStore.readCapsuleArray.mockImplementation((address, slot) => + Promise.resolve(capsuleArrays.get(`${address.toString()}:${slot.toString()}`) ?? []), + ); + capsuleStore.setCapsuleArray.mockImplementation((address, slot, content) => { + capsuleArrays.set(`${address.toString()}:${slot.toString()}`, content); + return Promise.resolve(); + }); + capsuleStore.deleteCapsule.mockImplementation(() => {}); + } + + /** Mocks aztecNode to return the expected TxEffect (with note hashes or nullifiers) based on tx hash. */ + async function mockNode() { + const uniqueNoteHash = await computeUniqueNoteHash( + noteNonce, + await siloNoteHash(noteContractAddress, noteHash), + ); + const noteTxEffect = TxEffect.empty(); + noteTxEffect.txHash = noteTxHash; + noteTxEffect.noteHashes = [uniqueNoteHash]; + + const siloedEventCommitment = await siloNullifier(eventContractAddress, eventCommitment); + const eventTxEffect = TxEffect.empty(); + eventTxEffect.txHash = eventTxHash; + eventTxEffect.nullifiers = [siloedEventCommitment]; + + const blockHash = BlockHash.random(); + aztecNode.getTxEffect.mockImplementation((txHash: TxHash) => { + const data = txHash.equals(noteTxHash) ? noteTxEffect : eventTxEffect; + return Promise.resolve({ + l2BlockNumber: BlockNumber(syncedBlockNumber - 1), + l2BlockHash: blockHash, + data, + txIndexInBlock: 0, + }); + }); + aztecNode.findLeavesIndexes.mockResolvedValue([undefined]); + } + + function assertNoteStoredCorrectly() { + expect(noteStore.addNotes).toHaveBeenCalledTimes(1); + const storedNotes: NoteDao[] = noteStore.addNotes.mock.calls[0][0]; + expect(storedNotes).toHaveLength(1); + const noteDao = storedNotes[0]; + expect(noteDao.note).toEqual(new Note([new Fr(4), new Fr(5)])); + expect(noteDao.contractAddress).toEqual(noteContractAddress); + expect(noteDao.owner).toEqual(AztecAddress.fromField(new Fr(50))); + expect(noteDao.storageSlot).toEqual(new Fr(2)); + expect(noteDao.randomness).toEqual(new Fr(42)); + expect(noteDao.noteNonce).toEqual(noteNonce); + expect(noteDao.noteHash).toEqual(noteHash); + expect(noteDao.txHash).toEqual(noteTxHash); + } + + function assertEventStoredCorrectly() { + expect(privateEventStore.storePrivateEventLog).toHaveBeenCalledTimes(1); + const call = privateEventStore.storePrivateEventLog.mock.calls[0]; + expect(call[0]).toEqual(EventSelector.fromField(new Fr(20))); + expect(call[1]).toEqual(new Fr(30)); + expect(call[2]).toEqual([new Fr(40), new Fr(50)]); + expect(call[4].contractAddress).toEqual(eventContractAddress); + expect(call[4].txHash).toEqual(eventTxHash); + expect(call[4].scope).toEqual(AztecAddress.fromField(new Fr(90))); + } + + function assertCapsulesCleanedUp() { + expect(capsuleStore.deleteCapsule).toHaveBeenCalledWith( + contractAddress, + NOTE_BOUNDED_VEC_CAPACITY_SLOT, + 'test-job-id', + ); + expect(capsuleStore.deleteCapsule).toHaveBeenCalledWith( + contractAddress, + EVENT_BOUNDED_VEC_CAPACITY_SLOT, + 'test-job-id', + ); + expect(capsuleStore.setCapsuleArray).toHaveBeenCalledWith(contractAddress, noteSlot, [], 'test-job-id'); + expect(capsuleStore.setCapsuleArray).toHaveBeenCalledWith(contractAddress, eventSlot, [], 'test-job-id'); + } + }); }); }); 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 8618c0b21a6b..05d54f5d3e0f 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 @@ -44,8 +44,10 @@ import type { IMiscOracle, IUtilityExecutionOracle, NoteData } from './interface import { MessageLoadOracleInputs } from './message_load_oracle_inputs.js'; // Capsule slots where aztec-nr stores BoundedVec capacities (must match Noir globals in messages/processing/mod.nr). -const NOTE_BOUNDED_VEC_CAPACITY_SLOT = sha256ToField([Buffer.from('AZTEC_NR::NOTE_BOUNDED_VEC_CAPACITY_SLOT')]); -const EVENT_BOUNDED_VEC_CAPACITY_SLOT = sha256ToField([Buffer.from('AZTEC_NR::EVENT_BOUNDED_VEC_CAPACITY_SLOT')]); +export const NOTE_BOUNDED_VEC_CAPACITY_SLOT = sha256ToField([Buffer.from('AZTEC_NR::NOTE_BOUNDED_VEC_CAPACITY_SLOT')]); +export const EVENT_BOUNDED_VEC_CAPACITY_SLOT = sha256ToField([ + Buffer.from('AZTEC_NR::EVENT_BOUNDED_VEC_CAPACITY_SLOT'), +]); /** Args for UtilityExecutionOracle constructor. */ export type UtilityExecutionOracleArgs = { From 4d650041541c9bded8dc594060f2552e1903f8f8 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Wed, 4 Mar 2026 11:03:14 -0300 Subject: [PATCH 7/7] chore(pxe): rename arraySize to capacity in validation request test names --- .../noir-structs/event_validation_request.test.ts | 8 ++++---- .../noir-structs/note_validation_request.test.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts index 9cb0ab16d09a..05eae4398fff 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/event_validation_request.test.ts @@ -6,7 +6,7 @@ import { TxHash } from '@aztec/stdlib/tx'; import { EventValidationRequest } from './event_validation_request.js'; describe('EventValidationRequest', () => { - it('deserializes with default capacity when no arraySize is given', () => { + it('deserializes with default capacity when no capacity is given', () => { // 11 storage fields = default BoundedVec capacity (default) const serialized = [ 1, // contract_address @@ -40,7 +40,7 @@ describe('EventValidationRequest', () => { expect(request.recipient).toEqual(AztecAddress.fromBigInt(8n)); }); - it('deserializes with explicit arraySize matching current capacity', () => { + it('deserializes with explicit capacity matching current capacity', () => { // 10 storage fields = current BoundedVec capacity const serialized = [ 1, // contract_address @@ -69,8 +69,8 @@ describe('EventValidationRequest', () => { expect(request.eventCommitment).toEqual(new Fr(6)); }); - it('throws if arraySize does not match actual field count (reader not exhausted)', () => { - // Data has 11 storage fields but we claim arraySize=10, leaving 1 unconsumed field + it('throws if capacity does not match actual field count (reader not exhausted)', () => { + // Data has 11 storage fields but we claim capacity=10, leaving 1 unconsumed field const serialized = [ 1, 2, diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts index ec48d36a254e..dc673588b8d6 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/note_validation_request.test.ts @@ -5,7 +5,7 @@ import { TxHash } from '@aztec/stdlib/tx'; import { NoteValidationRequest } from './note_validation_request.js'; describe('NoteValidationRequest', () => { - it('deserializes with default capacity when no arraySize is given', () => { + it('deserializes with default capacity when no capacity is given', () => { // 9 storage fields = old BoundedVec capacity (default) const serialized = [ 1, // contract address @@ -43,7 +43,7 @@ describe('NoteValidationRequest', () => { expect(request.recipient).toEqual(AztecAddress.fromBigInt(9n)); }); - it('deserializes with explicit arraySize matching current capacity', () => { + it('deserializes with explicit capacity matching current capacity', () => { // 8 storage fields = current BoundedVec capacity const serialized = [ 1, // contract address @@ -73,8 +73,8 @@ describe('NoteValidationRequest', () => { expect(request.noteHash).toEqual(new Fr(6)); }); - it('throws if arraySize does not match actual field count (reader not exhausted)', () => { - // Data has 9 storage fields but we claim arraySize=8, leaving 1 unconsumed field + it('throws if capacity does not match actual field count (reader not exhausted)', () => { + // Data has 9 storage fields but we claim capacity=8, leaving 1 unconsumed field const serialized = [ 1, 2,