From c6a451391fb07cd0ccf0e8e3c9c243e48b38384f Mon Sep 17 00:00:00 2001 From: Alejo Amiras Date: Wed, 19 Aug 2026 15:00:53 +0000 Subject: [PATCH] fix(nft)!: align note-hashing with upstream aztec-nr scheme BREAKING: changes NFTNote hashes, so a fixed contract deploys under a new class id. Existing on-chain NFT notes were created under the old scheme and will not match; in-flight partial notes must be drained or abandoned before upgrading code at an existing address, and PXE/wallet note databases must use the artifact matching each deployed class. nft_note.nr used a pre-refactor hashing scheme that diverged from both upstream and this repo's own multitoken_note.nr: - compute_partial_commitment used the generic DOM_SEP__NOTE_HASH; now uses the dedicated DOM_SEP__PARTIAL_NOTE_COMMITMENT. - compute_complete_note_hash hand-rolled the preimage with the storage slot in the middle ([commitment, storage_slot, token_id]); now calls aztec-nr's compute_note_hash(storage_slot, [commitment, token_id]), which fixes the slot first (preventing cross-slot collisions). Both the direct note-hash path and the partial-note completion path route through the same two functions, so directly-created and completed-partial notes remain indistinguishable. Ported upstream's note_hash_matches_completed_partial_note_hash test to lock that invariant, and corrected two comments that wrongly claimed the partial commitment includes the storage slot. Validated: nft_contract 65 Noir tests, NFT TS integration suite 9/9 against a local sandbox (proving PXE note discovery works with the new scheme), aztec compile OK. Codex adversarial review: matches upstream, correct, internally consistent. Co-Authored-By: Claude Fable 5 --- src/nft_contract/src/types/nft_note.nr | 59 ++++++++++++++++++++------ 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/src/nft_contract/src/types/nft_note.nr b/src/nft_contract/src/types/nft_note.nr index ebfb9e5d..40d3cdcc 100644 --- a/src/nft_contract/src/types/nft_note.nr +++ b/src/nft_contract/src/types/nft_note.nr @@ -6,12 +6,15 @@ use aztec::{ delivery::{do_private_message_delivery, MessageDelivery}, logs::partial_note::encode_partial_note_private_message, }, - note::{note_interface::{NoteHash, NoteType}, utils::compute_note_nullifier}, + note::{ + note_interface::{NoteHash, NoteType}, + utils::{compute_note_hash, compute_note_nullifier}, + }, oracle::random::random, protocol::{ address::AztecAddress, constants::{ - DOM_SEP__NOTE_COMPLETION_LOG_TAG, DOM_SEP__NOTE_HASH, + DOM_SEP__NOTE_COMPLETION_LOG_TAG, DOM_SEP__PARTIAL_NOTE_COMMITMENT, DOM_SEP__PARTIAL_NOTE_VALIDITY_COMMITMENT, }, hash::{compute_log_tag, poseidon2_hash_with_separator}, @@ -49,7 +52,7 @@ impl NoteHash for NFTNote { // values, so that notes all behave the same way regardless of how they were created. To achieve this, we // perform both steps of the partial note computation. - // First we create the partial note from a commitment to the private content (including storage slot). + // First we create the partial note from a commitment to the private content. let partial_note = PartialNFTNote { commitment: compute_partial_commitment(owner, randomness) }; @@ -151,7 +154,10 @@ impl NFTNote { /// Computes a commitment to the private content of a partial NFTNote, i.e. the fields that will remain private. All /// other note fields will be made public. fn compute_partial_commitment(owner: AztecAddress, randomness: Field) -> Field { - poseidon2_hash_with_separator([owner.to_field(), randomness], DOM_SEP__NOTE_HASH) + poseidon2_hash_with_separator( + [owner.to_field(), randomness], + DOM_SEP__PARTIAL_NOTE_COMMITMENT, + ) } #[derive(Packable)] @@ -165,8 +171,9 @@ impl NoteType for NFTPartialNotePrivateLogContent { } } -/// A partial instance of a NFTNote. This value represents a private commitment to the owner, randomness and storage -/// slot, but the token id field has not yet been set. A partial note can be completed in public with the `complete` +/// A partial instance of a NFTNote. This value represents a private commitment to the owner and randomness (the storage +/// slot is folded into the completed note hash, not the commitment), but the token id field has not yet been set. A +/// partial note can be completed in public with the `complete` /// function (revealing the token id to the public), resulting in a NFTNote that can be used like any other one (except /// of course that its token id is known). #[derive(Packable, Serialize, Deserialize)] @@ -224,12 +231,38 @@ impl PartialNFTNote { } fn compute_complete_note_hash(self, storage_slot: Field, token_id: Field) -> Field { - // Here we finalize the note hash by including the (public) storage slot and token id into the partial note - // commitment. Note that we use the same separator as we used for the first round of poseidon - this is not - // an issue. - poseidon2_hash_with_separator( - [self.commitment, storage_slot, token_id], - DOM_SEP__NOTE_HASH, - ) + // Finalize the note hash by folding the (public) storage slot and token id into the partial note commitment. + // `compute_note_hash` fixes the storage slot at the first position of the preimage (preventing cross-slot + // collisions) and appends the remaining fields in order. + compute_note_hash(storage_slot, [self.commitment, token_id]) + } +} + +mod test { + use super::{compute_partial_commitment, NFTNote, PartialNFTNote}; + use aztec::{ + note::note_interface::NoteHash, + protocol::{address::AztecAddress, traits::FromField}, + }; + + global token_id: Field = 17; + global randomness: Field = 42; + global owner: AztecAddress = AztecAddress::from_field(50); + global storage_slot: Field = 13; + + #[test] + fn note_hash_matches_completed_partial_note_hash() { + // A NFTNote must have the same note hash as a PartialNFTNote created and then completed with the same private + // values. This requires the same hash function in both flows, with the fields in the same order — the invariant + // that lets a directly-created note and a completed partial note be used interchangeably. + let note = NFTNote { token_id }; + let note_hash = note.compute_note_hash(owner, storage_slot, randomness); + + let partial_note = + PartialNFTNote { commitment: compute_partial_commitment(owner, randomness) }; + let completed_partial_note_hash = + partial_note.compute_complete_note_hash(storage_slot, token_id); + + assert_eq(note_hash, completed_partial_note_hash); } }