Skip to content

fix(nft)!: align note-hashing with upstream aztec-nr scheme - #37

Draft
alejoamiras wants to merge 3 commits into
stack/closeout-cancel-authwitfrom
stack/closeout-nft-notehash
Draft

fix(nft)!: align note-hashing with upstream aztec-nr scheme#37
alejoamiras wants to merge 3 commits into
stack/closeout-cancel-authwitfrom
stack/closeout-nft-notehash

Conversation

@alejoamiras

@alejoamiras alejoamiras commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Part of a 4-PR all-or-nothing stack (#35#36#37#38). Drafts, proposed as one unit.

⚠️ BREAKING — changes NFT note hashes

A fixed contract deploys under a new class id. Existing on-chain NFTNotes were created under the old scheme and will not match. Practically:

  • Drain or abandon in-flight partial notes before upgrading code at an existing address — the change also alters validity commitments, completion-log tags and commitment-storage keys, not just final note hashes.
  • PXE/wallet note databases and clients must use the artifact matching each deployed class. Old instances remain valid with their old artifact.

What changed

nft_note.nr used a pre-refactor hashing scheme that diverged from upstream and from this repo's own multitoken_note.nr — two custom notes in one repo disagreeing for no reason:

before after (= upstream)
compute_partial_commitment generic DOM_SEP__NOTE_HASH dedicated DOM_SEP__PARTIAL_NOTE_COMMITMENT
compute_complete_note_hash hand-rolled [commitment, storage_slot, token_id] (slot in the middle) compute_note_hash(storage_slot, [commitment, token_id]) — the aztec-nr util, slot first

Both the direct note-hash path and the partial-note completion path route through these two functions, so directly-created and completed-partial notes stay 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.

Validation

nft_contract 65 Noir tests · NFT TS integration 9/9 against a local sandbox — the load-bearing proof that PXE note discovery works under the new scheme · aztec compile OK.

Codex adversarial review: "matches upstream, correct, internally consistent."

alejoamiras and others added 3 commits August 19, 2026 16:09
Documentation follow-ups from the 2026-08 security audit. No contract
logic changes.

- Vault (main.nr): a prominent block comment at the top records that the
  ~15 `Order matters: … reentrancy` orderings are necessary but NOT
  sufficient — they do not hold when the asset or shares token has an
  ARC-403 hook, because the hook runs inside the transfer before the
  balance moves (audit F-001/F-002). The recurring inline phrase
  "to neutralize ARC-403 reentrancy" asserted a guarantee the code does
  not provide; corrected to point at that note.
- MultiToken (README): add a status warning. It was the only token
  contract with no per-file caveat, yet it carries the commitment
  trust-model issue (a commitment binds neither id nor amount, so it is
  not a payment guarantee). Kept severity-accurate: it references the
  repo-wide unaudited status rather than implying it is as unfinished as
  the Vault.
- Dripper (README): the existing "dev/testing only" note did not name
  the mechanism. Sharpened to state it is an uncapped, permissionless
  minter whose sole safety boundary is never being a valuable token's
  minter (audit F-006).

Validated: aztec-nargo fmt --check clean, aztec compile OK.
Upstream-parity function present in every upstream reference token
contract but absent from all three of ours. Without it, a private
authwit that has been granted but not yet consumed cannot be revoked.

Ported verbatim from the upstream Token/NFT contracts (aztec-packages
v5.1.0):

    #[external("private")]
    fn cancel_authwit(inner_hash: Field) {
        let on_behalf_of = self.msg_sender();
        let nullifier = compute_authwit_nullifier(on_behalf_of, inner_hash);
        self.context.push_nullifier_unsafe(nullifier);
    }

Cancellation pre-emits the same authwit nullifier the consume path
emits, so a later authwit-gated call fails with a duplicate nullifier.

Three tests per contract: the negative case (cancel then attempt to
consume -> duplicate nullifiers), a positive control (same flow without
the cancel succeeds, attributing the failure to the cancel), and caller
isolation (a foreign account cancelling with the owner's exact inner
hash does NOT revoke the owner's authwit, since the nullifier is bound
to msg_sender). READMEs list the new function.

Additive: a new selector, no existing ABI touched.

Validated: token 84 / nft 64 / multitoken 80 Noir tests, aztec compile
OK. Codex adversarial review: safe and upstream-equivalent; its two
suggestions (tighten the negative assertion, add the caller-isolation
test) are applied — note its proposed error string 'already present'
was empirically wrong, the matchable revert is 'duplicate nullifiers'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@alejoamiras
alejoamiras force-pushed the stack/closeout-nft-notehash branch from d17b64d to 252c352 Compare August 19, 2026 16:16
@github-actions

Copy link
Copy Markdown

Benchmark Comparison

CPU Cores RAM Arch
AMD EPYC 7763 64-Core Processor 4 16 GiB x64

Contract: escrow

🚦 Function Gates DA Gas L2 Gas Proving Time (ms)
Base PR Diff Base PR Diff Base PR Diff Base PR Diff
(partial) withdraw 485,780 485,780 3,744 3,744 587,800 587,800 14,023 13,789 -234 (-1.7%)
withdraw 330,054 330,054 832 832 499,700 499,700 11,274 11,107 -167 (-1.5%)
withdraw_nft 378,231 378,232 +1 (+0.0%) 1,440 1,440 527,400 527,400 12,389 12,297 -92 (-0.7%)

Contract: logic

🚦 Function Gates DA Gas L2 Gas Proving Time (ms)
Base PR Diff Base PR Diff Base PR Diff Base PR Diff
get_escrow 390,336 390,336 192 192 456,000 456,000 12,581 12,352 -229 (-1.8%)
secret_key_to_public_keys 387,695 387,695 192 192 456,000 456,000 12,616 12,364 -252 (-2.0%)
share_escrow 304,211 304,211 1,952 1,952 520,700 520,700 11,064 10,932 -132 (-1.2%)

Contract: multitoken

🚦 Function Gates DA Gas L2 Gas Proving Time (ms)
Base PR Diff Base PR Diff Base PR Diff Base PR Diff
burn_private 260,232 260,232 832 832 499,700 499,700 10,046 10,134 +88 (+0.9%)
burn_public 223,800 223,800 416 416 638,292 638,292 9,662 9,604 -58 (-0.6%)
initialize_transfer_commitment 225,054 225,054 768 768 474,500 474,500 9,644 9,573 -71 (-0.7%)
mint_to_private 297,795 297,795 1,408 1,408 511,400 511,400 10,974 10,816 -158 (-1.4%)
mint_to_public 223,800 223,800 416 416 637,800 637,800 9,721 9,607 -114 (-1.2%)
transfer_private_to_commitment 263,571 263,571 1,024 1,024 511,400 511,400 10,105 10,106 +1 (+0.0%)
transfer_private_to_private 336,124 336,124 2,048 2,048 555,100 555,100 11,445 11,278 -167 (-1.5%)
transfer_private_to_public 306,785 306,785 1,056 1,056 714,677 714,677 10,990 10,856 -134 (-1.2%)
transfer_public_to_commitment 223,800 223,800 640 640 663,516 663,516 9,709 9,631 -78 (-0.8%)
transfer_public_to_private 298,556 298,556 1,024 1,024 683,958 683,958 10,856 10,728 -128 (-1.2%)
transfer_public_to_public 223,800 223,800 480 480 673,505 673,505 9,663 9,606 -57 (-0.6%)

Contract: nft

🚦 Function Gates DA Gas L2 Gas Proving Time (ms)
Base PR Diff Base PR Diff Base PR Diff Base PR Diff
burn_private 275,232 275,233 +1 (+0.0%) 416 416 661,052 661,052 10,560 10,466 -94 (-0.9%)
burn_public 223,800 223,800 448 448 670,034 670,034 9,686 9,616 -70 (-0.7%)
mint_to_private 341,272 341,273 +1 (+0.0%) 1,600 1,600 735,342 735,342 11,406 11,423 +17 (+0.1%)
mint_to_public 223,800 223,800 448 448 670,718 670,718 9,638 9,553 -85 (-0.9%)
transfer_private_to_private 249,433 249,434 +1 (+0.0%) 832 832 499,700 499,700 9,959 9,869 -90 (-0.9%)
transfer_private_to_public 275,261 275,262 +1 (+0.0%) 416 416 659,258 659,258 10,582 10,534 -48 (-0.5%)
transfer_public_to_private 292,557 292,558 +1 (+0.0%) 992 992 683,268 683,268 10,728 10,703 -25 (-0.2%)
transfer_public_to_public 223,800 223,800 384 384 633,285 633,285 9,678 9,640 -38 (-0.4%)

Contract: token

🚦 Function Gates DA Gas L2 Gas Proving Time (ms)
Base PR Diff Base PR Diff Base PR Diff Base PR Diff
burn_private 303,054 303,054 1,024 1,024 713,957 713,957 10,931 10,818 -113 (-1.0%)
burn_public 223,800 223,800 448 448 672,632 672,632 9,648 9,708 +60 (+0.6%)
initialize_transfer_commitment 225,054 225,054 768 768 474,500 474,500 9,662 9,563 -99 (-1.0%)
mint_to_private 355,507 355,507 2,144 2,144 738,070 738,070 11,803 11,594 -209 (-1.8%)
mint_to_public 223,800 223,800 448 448 672,227 672,227 9,772 9,805 +33 (+0.3%)
transfer_private_to_commitment 259,896 259,896 992 992 511,400 511,400 10,107 10,043 -64 (-0.6%)
transfer_private_to_private 343,699 343,699 2,592 2,592 557,600 557,600 11,531 11,360 -171 (-1.5%)
transfer_private_to_public 303,110 303,110 1,024 1,024 714,023 714,023 11,004 10,811 -193 (-1.8%)
transfer_private_to_public_with_commitment 307,280 307,280 1,600 1,600 747,323 747,323 10,973 10,917 -56 (-0.5%)
transfer_public_to_commitment 223,800 223,800 576 576 662,529 662,529 9,625 9,674 +49 (+0.5%)
transfer_public_to_private 296,520 296,520 992 992 683,304 683,304 10,785 10,747 -38 (-0.4%)
transfer_public_to_public 223,800 223,800 448 448 672,548 672,548 9,706 9,602 -104 (-1.1%)

Contract: vault

🚦 Function Gates DA Gas L2 Gas Proving Time (ms)
Base PR Diff Base PR Diff Base PR Diff Base PR Diff
deposit_private_to_private 507,983 507,983 1,312 1,312 878,248 878,248 14,257 14,094 -163 (-1.1%)
deposit_private_to_private_exact 611,708 611,708 1,888 1,888 915,316 915,316 16,134 15,930 -204 (-1.3%)
deposit_private_to_public 435,487 435,487 768 768 862,273 862,273 13,184 13,018 -166 (-1.3%)
deposit_public_to_private 424,931 424,931 1,984 1,984 965,045 965,045 13,042 12,965 -77 (-0.6%)
deposit_public_to_private_exact 424,942 424,942 1,952 1,952 949,469 949,469 13,115 12,860 -255 (-1.9%)
deposit_public_to_public 223,800 223,800 832 832 897,563 897,563 9,641 9,593 -48 (-0.5%)
issue_private_to_private_exact 611,708 611,708 1,888 1,888 915,976 915,976 16,095 15,978 -117 (-0.7%)
issue_private_to_public_exact 484,222 484,222 1,344 1,344 899,998 899,998 13,791 13,771 -20 (-0.1%)
issue_public_to_private 346,383 346,383 1,376 1,376 921,661 921,661 11,563 11,459 -104 (-0.9%)
issue_public_to_public 223,800 223,800 832 832 898,253 898,253 9,697 9,687 -10 (-0.1%)
redeem_private_to_private_exact 614,712 614,712 1,888 1,888 915,823 915,823 16,059 16,042 -17 (-0.1%)
redeem_private_to_public 435,430 435,430 768 768 862,717 862,717 13,015 12,978 -37 (-0.3%)
redeem_public_to_private_exact 428,003 428,003 1,952 1,952 949,790 949,790 13,135 12,922 -213 (-1.6%)
redeem_public_to_public 223,800 223,800 832 832 898,085 898,085 9,652 9,579 -73 (-0.8%)
withdraw_private_to_private 510,987 510,987 1,312 1,312 878,458 878,458 14,131 14,095 -36 (-0.3%)
withdraw_private_to_private_exact 614,712 614,712 1,888 1,888 915,769 915,769 16,129 15,995 -134 (-0.8%)
withdraw_private_to_public_exact 484,165 484,165 1,344 1,344 900,232 900,232 13,865 13,781 -84 (-0.6%)
withdraw_public_to_private 441,260 441,260 2,528 2,528 967,356 967,356 13,197 13,153 -44 (-0.3%)
withdraw_public_to_public 223,800 223,800 832 832 898,388 898,388 9,727 9,611 -116 (-1.2%)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant