From 578f67ca7df6ea0c66265bf60767803f692d46f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bene=C5=A1?= Date: Mon, 16 Sep 2024 11:12:40 +0200 Subject: [PATCH] feat: NFT with "transient" storage shield flow (#8129) --- cspell.json | 1 + .../aztec-nr/aztec/src/note/constants.nr | 8 +- .../aztec/src/note/note_getter_options.nr | 6 +- noir-projects/noir-contracts/Nargo.toml | 1 + .../src/types/card_note.nr | 2 - .../contracts/nft_contract/Nargo.toml | 10 + .../contracts/nft_contract/src/main.nr | 291 ++++++++++++++++++ .../contracts/nft_contract/src/test.nr | 7 + .../nft_contract/src/test/access_control.nr | 47 +++ .../nft_contract/src/test/minting.nr | 40 +++ .../src/test/transfer_in_private.nr | 117 +++++++ .../src/test/transfer_in_public.nr | 96 ++++++ .../src/test/transfer_to_private.nr | 130 ++++++++ .../src/test/transfer_to_public.nr | 80 +++++ .../contracts/nft_contract/src/test/utils.nr | 134 ++++++++ .../contracts/nft_contract/src/types.nr | 1 + .../nft_contract/src/types/nft_note.nr | 131 ++++++++ .../token_bridge_contract/src/main.nr | 3 +- .../contracts/token_contract/src/main.nr | 2 +- .../token_contract/src/test/access_control.nr | 2 +- .../token_contract/src/test/refunds.nr | 1 - .../token_contract/src/test/shielding.nr | 2 +- .../src/test/transfer_private.nr | 18 +- .../src/test/transfer_public.nr | 20 +- .../token_contract/src/test/unshielding.nr | 6 +- .../token_contract/src/types/token_note.nr | 3 +- .../src/abis/private_circuit_public_inputs.nr | 1 + .../src/abis/public_circuit_public_inputs.nr | 1 + .../crates/types/src/point.nr | 8 +- yarn-project/end-to-end/Earthfile | 3 + yarn-project/end-to-end/src/e2e_nft.test.ts | 164 ++++++++++ .../src/e2e_prover/e2e_prover_test.ts | 2 +- .../e2e_token_contract/access_control.test.ts | 2 +- .../e2e_token_contract/token_contract_test.ts | 2 +- .../src/shared/cross_chain_test_harness.ts | 2 +- 35 files changed, 1297 insertions(+), 47 deletions(-) create mode 100644 noir-projects/noir-contracts/contracts/nft_contract/Nargo.toml create mode 100644 noir-projects/noir-contracts/contracts/nft_contract/src/main.nr create mode 100644 noir-projects/noir-contracts/contracts/nft_contract/src/test.nr create mode 100644 noir-projects/noir-contracts/contracts/nft_contract/src/test/access_control.nr create mode 100644 noir-projects/noir-contracts/contracts/nft_contract/src/test/minting.nr create mode 100644 noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_in_private.nr create mode 100644 noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_in_public.nr create mode 100644 noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_to_private.nr create mode 100644 noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_to_public.nr create mode 100644 noir-projects/noir-contracts/contracts/nft_contract/src/test/utils.nr create mode 100644 noir-projects/noir-contracts/contracts/nft_contract/src/types.nr create mode 100644 noir-projects/noir-contracts/contracts/nft_contract/src/types/nft_note.nr create mode 100644 yarn-project/end-to-end/src/e2e_nft.test.ts diff --git a/cspell.json b/cspell.json index b6110189f08..e405fe53f0b 100644 --- a/cspell.json +++ b/cspell.json @@ -269,6 +269,7 @@ "unprefixed", "unshield", "unshielding", + "unshields", "unzipit", "updateable", "upperfirst", diff --git a/noir-projects/aztec-nr/aztec/src/note/constants.nr b/noir-projects/aztec-nr/aztec/src/note/constants.nr index 3b238572e90..f948e023f88 100644 --- a/noir-projects/aztec-nr/aztec/src/note/constants.nr +++ b/noir-projects/aztec-nr/aztec/src/note/constants.nr @@ -1,6 +1,6 @@ -global MAX_NOTE_FIELDS_LENGTH: u64 = 20; +global MAX_NOTE_FIELDS_LENGTH: u32 = 20; // The plus 1 is 1 extra field for nonce. // + 2 for EXTRA_DATA: [number_of_return_notes, contract_address] -global GET_NOTE_ORACLE_RETURN_LENGTH: u64 = MAX_NOTE_FIELDS_LENGTH + 1 + 2; -global MAX_NOTES_PER_PAGE: u64 = 10; -global VIEW_NOTE_ORACLE_RETURN_LENGTH: u64 = MAX_NOTES_PER_PAGE * (MAX_NOTE_FIELDS_LENGTH + 1) + 2; +global GET_NOTE_ORACLE_RETURN_LENGTH: u32 = MAX_NOTE_FIELDS_LENGTH + 1 + 2; +global MAX_NOTES_PER_PAGE: u32 = 10; +global VIEW_NOTE_ORACLE_RETURN_LENGTH: u32 = MAX_NOTES_PER_PAGE * (MAX_NOTE_FIELDS_LENGTH + 1) + 2; diff --git a/noir-projects/aztec-nr/aztec/src/note/note_getter_options.nr b/noir-projects/aztec-nr/aztec/src/note/note_getter_options.nr index 7c2ff74a4ab..dc3143108df 100644 --- a/noir-projects/aztec-nr/aztec/src/note/note_getter_options.nr +++ b/noir-projects/aztec-nr/aztec/src/note/note_getter_options.nr @@ -3,9 +3,9 @@ use dep::protocol_types::{constants::MAX_NOTE_HASH_READ_REQUESTS_PER_CALL, trait use crate::note::note_interface::NoteInterface; struct PropertySelector { - index: u8, - offset: u8, - length: u8, + index: u8, // index of the field in the serialized note array + offset: u8, // offset in the byte representation of the field (selected with index above) from which to reading + length: u8, // number of bytes to read after the offset } struct Select { diff --git a/noir-projects/noir-contracts/Nargo.toml b/noir-projects/noir-contracts/Nargo.toml index f3d2b637a44..ddb146f2f2d 100644 --- a/noir-projects/noir-contracts/Nargo.toml +++ b/noir-projects/noir-contracts/Nargo.toml @@ -28,6 +28,7 @@ members = [ "contracts/key_registry_contract", "contracts/inclusion_proofs_contract", "contracts/lending_contract", + "contracts/nft_contract", "contracts/parent_contract", "contracts/pending_note_hashes_contract", "contracts/price_feed_contract", diff --git a/noir-projects/noir-contracts/contracts/docs_example_contract/src/types/card_note.nr b/noir-projects/noir-contracts/contracts/docs_example_contract/src/types/card_note.nr index 053db37aa93..5de9b5dea6d 100644 --- a/noir-projects/noir-contracts/contracts/docs_example_contract/src/types/card_note.nr +++ b/noir-projects/noir-contracts/contracts/docs_example_contract/src/types/card_note.nr @@ -4,8 +4,6 @@ use dep::aztec::{ protocol_types::{traits::Serialize, constants::GENERATOR_INDEX__NOTE_NULLIFIER, hash::poseidon2_hash_with_separator} }; -// Shows how to create a custom note - global CARD_NOTE_LEN: Field = 3; // CARD_NOTE_LEN * 32 + 32(storage_slot as bytes) + 32(note_type_id as bytes) global CARD_NOTE_BYTES_LEN: Field = 3 * 32 + 64; diff --git a/noir-projects/noir-contracts/contracts/nft_contract/Nargo.toml b/noir-projects/noir-contracts/contracts/nft_contract/Nargo.toml new file mode 100644 index 00000000000..8b1e9b7b438 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/nft_contract/Nargo.toml @@ -0,0 +1,10 @@ +[package] +name = "nft_contract" +authors = [""] +compiler_version = ">=0.25.0" +type = "contract" + +[dependencies] +aztec = { path = "../../../aztec-nr/aztec" } +compressed_string = { path = "../../../aztec-nr/compressed-string" } +authwit = { path = "../../../aztec-nr/authwit" } diff --git a/noir-projects/noir-contracts/contracts/nft_contract/src/main.nr b/noir-projects/noir-contracts/contracts/nft_contract/src/main.nr new file mode 100644 index 00000000000..dc11a6861c4 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/nft_contract/src/main.nr @@ -0,0 +1,291 @@ +mod types; +mod test; + +// Minimal NFT implementation with `AuthWit` support that allows minting in public-only and transfers in both public +// and private. +contract NFT { + use dep::compressed_string::FieldCompressedString; + use dep::aztec::{ + prelude::{NoteGetterOptions, NoteViewerOptions, Map, PublicMutable, SharedImmutable, PrivateSet, AztecAddress}, + encrypted_logs::{encrypted_note_emission::encode_and_encrypt_note_with_keys}, + hash::pedersen_hash, keys::getters::get_current_public_keys, + note::constants::MAX_NOTES_PER_PAGE, protocol_types::traits::is_empty, + utils::comparison::Comparator + }; + use dep::authwit::auth::{assert_current_call_valid_authwit, assert_current_call_valid_authwit_public, compute_authwit_nullifier}; + use crate::types::nft_note::{NFTNote, NFTNoteHidingPoint}; + + global TRANSIENT_STORAGE_SLOT_PEDERSEN_INDEX = 3; + + // TODO(#8467): Rename this to Transfer - calling this NFTTransfer to avoid export conflict with the Transfer event + // in the Token contract. + #[aztec(event)] + struct NFTTransfer { + from: AztecAddress, + to: AztecAddress, + token_id: Field, + } + + #[aztec(storage)] + struct Storage { + // The symbol of the NFT + symbol: SharedImmutable, + // The name of the NFT + name: SharedImmutable, + // The admin of the contract + admin: PublicMutable, + // Addresses that can mint + minters: Map>, + // Contains the NFTs owned by each address in private. + private_nfts: Map>, + // A map from token ID to a boolean indicating if the NFT exists. + nft_exists: Map>, + // A map from token ID to the public owner of the NFT. + public_owners: Map>, + } + + #[aztec(public)] + #[aztec(initializer)] + fn constructor(admin: AztecAddress, name: str<31>, symbol: str<31>) { + assert(!admin.is_zero(), "invalid admin"); + storage.admin.write(admin); + storage.minters.at(admin).write(true); + storage.name.initialize(FieldCompressedString::from_string(name)); + storage.symbol.initialize(FieldCompressedString::from_string(symbol)); + } + + #[aztec(public)] + fn set_admin(new_admin: AztecAddress) { + assert(storage.admin.read().eq(context.msg_sender()), "caller is not an admin"); + storage.admin.write(new_admin); + } + + #[aztec(public)] + fn set_minter(minter: AztecAddress, approve: bool) { + assert(storage.admin.read().eq(context.msg_sender()), "caller is not an admin"); + storage.minters.at(minter).write(approve); + } + + #[aztec(public)] + fn mint(to: AztecAddress, token_id: Field) { + assert(token_id != 0, "zero token ID not supported"); + assert(storage.minters.at(context.msg_sender()).read(), "caller is not a minter"); + assert(storage.nft_exists.at(token_id).read() == false, "token already exists"); + + storage.nft_exists.at(token_id).write(true); + + storage.public_owners.at(token_id).write(to); + } + + #[aztec(public)] + #[aztec(view)] + fn public_get_name() -> pub FieldCompressedString { + storage.name.read_public() + } + + #[aztec(private)] + #[aztec(view)] + fn private_get_name() -> pub FieldCompressedString { + storage.name.read_private() + } + + #[aztec(public)] + #[aztec(view)] + fn public_get_symbol() -> pub FieldCompressedString { + storage.symbol.read_public() + } + + #[aztec(private)] + #[aztec(view)] + fn private_get_symbol() -> pub FieldCompressedString { + storage.symbol.read_private() + } + + #[aztec(public)] + #[aztec(view)] + fn get_admin() -> Field { + storage.admin.read().to_field() + } + + #[aztec(public)] + #[aztec(view)] + fn is_minter(minter: AztecAddress) -> bool { + storage.minters.at(minter).read() + } + + #[aztec(public)] + fn transfer_in_public(from: AztecAddress, to: AztecAddress, token_id: Field, nonce: Field) { + if (!from.eq(context.msg_sender())) { + assert_current_call_valid_authwit_public(&mut context, from); + } else { + assert(nonce == 0, "invalid nonce"); + } + + let public_owners_storage = storage.public_owners.at(token_id); + assert(public_owners_storage.read().eq(from), "invalid owner"); + + public_owners_storage.write(to); + } + + /// Prepares a transfer from public balance of `from` to a private balance of `to`. The transfer then needs to be + /// finalized by calling `finalize_transfer_to_private`. `transient_storage_slot_randomness` is passed + /// as an argument so that we can derive `transfer_preparer_storage_slot_commitment` off-chain and then pass it + /// as an argument to the followup call to `finalize_transfer_to_private`. + // TODO(#8238): Remove the `note_randomness` argument below once we have partial notes delivery (then we can just + // fetch the randomness from oracle). + #[aztec(private)] + fn prepare_transfer_to_private( + from: AztecAddress, + to: AztecAddress, + note_randomness: Field, + transient_storage_slot_randomness: Field + ) { + // We create a partial NFT note hiding point with unpopulated/zero token id for 'to' + let to_npk_m_hash = get_current_public_keys(&mut context, to).npk_m.hash(); + let to_note_slot = storage.private_nfts.at(to).storage_slot; + let hiding_point = NFTNoteHidingPoint::new(to_npk_m_hash, to_note_slot, note_randomness); + + // We make the msg_sender/transfer_preparer part of the slot preimage to ensure he cannot interfere with + // non-sender's slots + let transfer_preparer_storage_slot_commitment: Field = pedersen_hash( + [context.msg_sender().to_field(), transient_storage_slot_randomness], + TRANSIENT_STORAGE_SLOT_PEDERSEN_INDEX + ); + // Then we hash the transfer preparer storage slot commitment with `from` and use that as the final slot + // --> by hashing it with a `from` we ensure that `from` cannot interfere with slots not assigned to him. + let slot: Field = pedersen_hash( + [from.to_field(), transfer_preparer_storage_slot_commitment], + TRANSIENT_STORAGE_SLOT_PEDERSEN_INDEX + ); + + NFT::at(context.this_address())._store_point_in_transient_storage(hiding_point, slot).enqueue(&mut context); + } + + #[aztec(public)] + #[aztec(internal)] + fn _store_point_in_transient_storage(point: NFTNoteHidingPoint, slot: Field) { + // We don't perform check for the overwritten value to be non-zero because the slots are siloed to `to` + // and hence `to` can interfere only with his own execution. + context.storage_write(slot, point); + } + + /// Finalizes a transfer of NFT with `token_id` from public balance of `from` to a private balance of `to`. + /// The transfer must be prepared by calling `prepare_transfer_to_private` first. + /// The `transfer_preparer_storage_slot_commitment` has to be computed off-chain the same way as was done + /// in the preparation call. + #[aztec(public)] + fn finalize_transfer_to_private(token_id: Field, transfer_preparer_storage_slot_commitment: Field) { + // We don't need to support authwit here because `prepare_transfer_to_private` allows us to set arbitrary + // `from` and `from` will always be the msg sender here. + let from = context.msg_sender(); + let public_owners_storage = storage.public_owners.at(token_id); + assert(public_owners_storage.read().eq(from), "invalid NFT owner"); + + // Derive the slot from the transfer preparer storage slot commitment and the `from` address (declared + // as `from` in this function) + let hiding_point_slot = pedersen_hash( + [from.to_field(), transfer_preparer_storage_slot_commitment], + TRANSIENT_STORAGE_SLOT_PEDERSEN_INDEX + ); + + // Read the hiding point from "transient" storage and check it's not empty to ensure the transfer was prepared + let mut hiding_point: NFTNoteHidingPoint = context.storage_read(hiding_point_slot); + assert(!is_empty(hiding_point), "transfer not prepared"); + + // Set the public NFT owner to zero + public_owners_storage.write(AztecAddress::zero()); + + // Finalize the hiding point with the `token_id` and insert the note + let note_hash = hiding_point.finalize(token_id); + context.push_note_hash(note_hash); + + // At last we reset public storage to zero to achieve the effect of transient storage - kernels will squash + // the writes + context.storage_write(hiding_point_slot, NFTNoteHidingPoint::empty()); + } + + /** + * Cancel a private authentication witness. + * @param inner_hash The inner hash of the authwit to cancel. + */ + #[aztec(private)] + fn cancel_authwit(inner_hash: Field) { + let on_behalf_of = context.msg_sender(); + let nullifier = compute_authwit_nullifier(on_behalf_of, inner_hash); + context.push_nullifier(nullifier); + } + + #[aztec(private)] + fn transfer_in_private(from: AztecAddress, to: AztecAddress, token_id: Field, nonce: Field) { + if (!from.eq(context.msg_sender())) { + assert_current_call_valid_authwit(&mut context, from); + } else { + assert(nonce == 0, "invalid nonce"); + } + + let nfts = storage.private_nfts; + + let notes = nfts.at(from).pop_notes( + NoteGetterOptions::new().select(NFTNote::properties().token_id, Comparator.EQ, token_id).set_limit(1) + ); + assert(notes.len() == 1, "NFT not found when transferring"); + + let from_ovpk_m = get_current_public_keys(&mut context, from).ovpk_m; + let to_keys = get_current_public_keys(&mut context, to); + + let new_note = NFTNote::new(token_id, to_keys.npk_m.hash()); + nfts.at(to).insert(&mut new_note).emit(encode_and_encrypt_note_with_keys(&mut context, from_ovpk_m, to_keys.ivpk_m, to)); + } + + #[aztec(private)] + fn transfer_to_public(from: AztecAddress, to: AztecAddress, token_id: Field, nonce: Field) { + if (!from.eq(context.msg_sender())) { + assert_current_call_valid_authwit(&mut context, from); + } else { + assert(nonce == 0, "invalid nonce"); + } + + let notes = storage.private_nfts.at(from).pop_notes( + NoteGetterOptions::new().select(NFTNote::properties().token_id, Comparator.EQ, token_id).set_limit(1) + ); + assert(notes.len() == 1, "NFT not found when transferring to public"); + + NFT::at(context.this_address())._finish_transfer_to_public(to, token_id).enqueue(&mut context); + } + + #[aztec(public)] + #[aztec(internal)] + fn _finish_transfer_to_public(to: AztecAddress, token_id: Field) { + storage.public_owners.at(token_id).write(to); + } + + // Returns zero address when the token does not have a public owner. Reverts if the token does not exist. + #[aztec(public)] + #[aztec(view)] + fn owner_of(token_id: Field) -> AztecAddress { + assert(storage.nft_exists.at(token_id).read(), "token does not exist"); + storage.public_owners.at(token_id).read() + } + + /// Returns an array of token IDs owned by `owner` in private and a flag indicating whether a page limit was + /// reached. Starts getting the notes from page with index `page_index`. Zero values in the array are placeholder + /// values for non-existing notes. + unconstrained fn get_private_nfts( + owner: AztecAddress, + page_index: u32 + ) -> pub ([Field; MAX_NOTES_PER_PAGE], bool) { + let offset = page_index * MAX_NOTES_PER_PAGE; + let mut options = NoteViewerOptions::new(); + let notes = storage.private_nfts.at(owner).view_notes(options.set_offset(offset)); + + let mut owned_nft_ids = [0; MAX_NOTES_PER_PAGE]; + for i in 0..options.limit { + if i < notes.len() { + owned_nft_ids[i] = notes.get_unchecked(i).token_id; + } + } + + let page_limit_reached = notes.len() == options.limit; + (owned_nft_ids, page_limit_reached) + } +} diff --git a/noir-projects/noir-contracts/contracts/nft_contract/src/test.nr b/noir-projects/noir-contracts/contracts/nft_contract/src/test.nr new file mode 100644 index 00000000000..a08dd7f0f13 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/nft_contract/src/test.nr @@ -0,0 +1,7 @@ +mod access_control; +mod minting; +mod transfer_in_private; +mod transfer_in_public; +mod transfer_to_private; +mod transfer_to_public; +mod utils; diff --git a/noir-projects/noir-contracts/contracts/nft_contract/src/test/access_control.nr b/noir-projects/noir-contracts/contracts/nft_contract/src/test/access_control.nr new file mode 100644 index 00000000000..f07c2c905c3 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/nft_contract/src/test/access_control.nr @@ -0,0 +1,47 @@ +use crate::test::utils; +use crate::NFT; + +#[test] +unconstrained fn access_control() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, owner, recipient) = utils::setup(/* with_account_contracts */ false); + + // Set a new admin + env.call_public(NFT::at(nft_contract_address).set_admin(recipient)); + + // Check it worked + let admin = env.call_public(NFT::at(nft_contract_address).get_admin()); + assert(admin == recipient.to_field()); + + // Impersonate new admin + env.impersonate(recipient); + + // Check new admin is not a minter + let is_minter_call_interface = NFT::at(nft_contract_address).is_minter(recipient); + let is_minter = env.call_public(is_minter_call_interface); + assert(is_minter == false); + // Set admin as minter + let set_minter_call_interface = NFT::at(nft_contract_address).set_minter(recipient, true); + env.call_public(set_minter_call_interface); + + // Check it worked + let is_minter = env.call_public(is_minter_call_interface); + assert(is_minter == true); + + // Revoke minter as admin + let set_minter_call_interface = NFT::at(nft_contract_address).set_minter(recipient, false); + env.call_public(set_minter_call_interface); + + // Check it worked + let is_minter = env.call_public(is_minter_call_interface); + assert(is_minter == false); + + // Impersonate original admin + env.impersonate(owner); + + // Try to set ourselves as admin, fail miserably + env.assert_public_call_fails(NFT::at(nft_contract_address).set_admin(recipient)); + + // Try to revoke minter status to recipient, fail miserably + env.assert_public_call_fails(NFT::at(nft_contract_address).set_minter(recipient, false)); +} diff --git a/noir-projects/noir-contracts/contracts/nft_contract/src/test/minting.nr b/noir-projects/noir-contracts/contracts/nft_contract/src/test/minting.nr new file mode 100644 index 00000000000..c50d28c1a6e --- /dev/null +++ b/noir-projects/noir-contracts/contracts/nft_contract/src/test/minting.nr @@ -0,0 +1,40 @@ +use crate::test::utils; +use crate::NFT; + +#[test] +unconstrained fn mint_success() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, owner, _) = utils::setup(/* with_account_contracts */ false); + + let token_id = 10000; + let mint_call_interface = NFT::at(nft_contract_address).mint(owner, token_id); + env.call_public(mint_call_interface); + + utils::assert_owns_public_nft(env, nft_contract_address, owner, token_id); +} + +#[test] +unconstrained fn mint_failures() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, owner, recipient) = utils::setup(/* with_account_contracts */ false); + + // MINTING AS A NON-MINTER + let token_id = 10000; + env.impersonate(recipient); + let mint_call_interface = NFT::at(nft_contract_address).mint(owner, token_id); + env.assert_public_call_fails(mint_call_interface); + + assert(!utils::get_nft_exists(nft_contract_address, token_id), "NFT minted by non-minter"); + + // MINTING THE SAME NFT TWICE + env.impersonate(owner); + env.call_public(mint_call_interface); + assert(utils::get_nft_exists(nft_contract_address, token_id), "NFT not minted"); + + // Second call should fail + env.assert_public_call_fails(mint_call_interface); + + // MINTING NFT WITH TOKEN ID 0 + let mint_call_interface = NFT::at(nft_contract_address).mint(owner, 0); + env.assert_public_call_fails(mint_call_interface); +} diff --git a/noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_in_private.nr b/noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_in_private.nr new file mode 100644 index 00000000000..817a30c8306 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_in_private.nr @@ -0,0 +1,117 @@ +use crate::test::utils; +use dep::aztec::test::helpers::cheatcodes; +use aztec::oracle::unsafe_rand::unsafe_rand; +use dep::authwit::cheatcodes as authwit_cheatcodes; +use crate::NFT; + +#[test] +unconstrained fn transfer_in_private() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, sender, recipient, token_id) = utils::setup_mint_and_transfer_to_private(/* with_account_contracts */ false); + + // Transfer the NFT to the recipient + env.call_private_void(NFT::at(nft_contract_address).transfer_in_private(sender, recipient, token_id, 0)); + + // Recipient should have the note in their private nfts + utils::assert_owns_private_nft(nft_contract_address, recipient, token_id); +} + +#[test] +unconstrained fn transfer_in_private_to_self() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, owner, _, token_id) = utils::setup_mint_and_transfer_to_private(/* with_account_contracts */ false); + + // Transfer the NFT back to the owner + env.call_private_void(NFT::at(nft_contract_address).transfer_in_private(owner, owner, token_id, 0)); + + // NFT owner should stay the same + utils::assert_owns_private_nft(nft_contract_address, owner, token_id); +} + +#[test] +unconstrained fn transfer_in_private_to_non_deployed_account() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, sender, _, token_id) = utils::setup_mint_and_transfer_to_private(/* with_account_contracts */ false); + let not_deployed = cheatcodes::create_account(); + + // Transfer the NFT to the recipient + env.call_private_void(NFT::at(nft_contract_address).transfer_in_private(sender, not_deployed.address, token_id, 0)); + + // Owner of the private NFT should be the not_deployed account + utils::assert_owns_private_nft(nft_contract_address, not_deployed.address, token_id); +} + +#[test] +unconstrained fn transfer_in_private_on_behalf_of_other() { + // Setup with account contracts. Slower since we actually deploy them, but needed for authwits. + let (env, nft_contract_address, sender, recipient, token_id) = utils::setup_mint_and_transfer_to_private(/* with_account_contracts */ true); + + // Transfer the NFT to the recipient + let transfer_in_private_call_interface = NFT::at(nft_contract_address).transfer_in_private(sender, recipient, token_id, 1); + authwit_cheatcodes::add_private_authwit_from_call_interface(sender, recipient, transfer_in_private_call_interface); + + // Impersonate recipient to perform the call + env.impersonate(recipient); + // Transfer the NFT to the recipient + env.call_private_void(transfer_in_private_call_interface); + + // Recipient should be the private NFT owner + utils::assert_owns_private_nft(nft_contract_address, recipient, token_id); +} + +#[test(should_fail_with="NFT not found when transferring")] +unconstrained fn transfer_in_private_failure_not_an_owner() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, owner, not_owner, token_id) = utils::setup_mint_and_transfer_to_private(/* with_account_contracts */ false); + // Try transferring the NFT from not_owner + env.impersonate(not_owner); + env.call_private_void(NFT::at(nft_contract_address).transfer_in_private(not_owner, owner, token_id, 0)); +} + +#[test(should_fail_with="invalid nonce")] +unconstrained fn transfer_in_private_failure_on_behalf_of_self_non_zero_nonce() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough. + // The nonce check is in the beginning so we don't need to waste time on minting the NFT and transferring + // it to private. + let (env, nft_contract_address, sender, recipient) = utils::setup(/* with_account_contracts */ false); + + // We set random value for the token_id as the nonce check is before we use the value. + let token_id = unsafe_rand(); + + // Try transferring the NFT + env.call_private_void(NFT::at(nft_contract_address).transfer_in_private(sender, recipient, token_id, 1)); +} + +#[test(should_fail_with="Authorization not found for message hash")] +unconstrained fn transfer_in_private_failure_on_behalf_of_other_without_approval() { + // Setup with account contracts. Slower since we actually deploy them, but needed for authwits. + // The authwit check is in the beginning so we don't need to waste time on minting the NFT and transferring + // it to private. + let (env, nft_contract_address, sender, recipient) = utils::setup(/* with_account_contracts */ true); + + // We set random value for the token_id as the nonce check is before we use the value. + let token_id = unsafe_rand(); + + // Impersonate recipient to perform the call + env.impersonate(recipient); + // Try transferring the NFT + env.call_private_void(NFT::at(nft_contract_address).transfer_in_private(sender, recipient, token_id, 1)); +} + +#[test(should_fail_with="Authorization not found for message hash")] +unconstrained fn transfer_in_private_failure_on_behalf_of_other_wrong_caller() { + // Setup with account contracts. Slower since we actually deploy them, but needed for authwits. + // The authwit check is in the beginning so we don't need to waste time on minting the NFT and transferring + // it to private. + let (env, nft_contract_address, sender, recipient) = utils::setup(/* with_account_contracts */ true); + + // We set random value for the token_id as the nonce check is before we use the value. + let token_id = unsafe_rand(); + + let transfer_in_private_from_call_interface = NFT::at(nft_contract_address).transfer_in_private(sender, recipient, token_id, 1); + authwit_cheatcodes::add_private_authwit_from_call_interface(sender, sender, transfer_in_private_from_call_interface); + // Impersonate recipient to perform the call + env.impersonate(recipient); + // Try transferring the NFT + env.call_private_void(transfer_in_private_from_call_interface); +} diff --git a/noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_in_public.nr b/noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_in_public.nr new file mode 100644 index 00000000000..81136a9bbeb --- /dev/null +++ b/noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_in_public.nr @@ -0,0 +1,96 @@ +use crate::test::utils; +use dep::aztec::oracle::unsafe_rand::unsafe_rand; +use dep::authwit::cheatcodes as authwit_cheatcodes; +use crate::NFT; + +#[test] +unconstrained fn transfer_in_public() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, sender, recipient, token_id) = utils::setup_and_mint(/* with_account_contracts */ false); + + // Transfer the NFT + env.call_public(NFT::at(nft_contract_address).transfer_in_public(sender, recipient, token_id, 0)); + + utils::assert_owns_public_nft(env, nft_contract_address, recipient, token_id); +} + +#[test] +unconstrained fn transfer_in_public_to_self() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, user, _, token_id) = utils::setup_and_mint(/* with_account_contracts */ false); + + // Transfer the NFT + env.call_public(NFT::at(nft_contract_address).transfer_in_public(user, user, token_id, 0)); + + // Check the user stayed the public owner + utils::assert_owns_public_nft(env, nft_contract_address, user, token_id); +} + +#[test] +unconstrained fn transfer_in_public_on_behalf_of_other() { + // Setup with account contracts. Slower since we actually deploy them, but needed for authwits. + let (env, nft_contract_address, sender, recipient, token_id) = utils::setup_and_mint(/* with_account_contracts */ true); + + let transfer_in_public_from_call_interface = NFT::at(nft_contract_address).transfer_in_public(sender, recipient, token_id, 1); + authwit_cheatcodes::add_public_authwit_from_call_interface(sender, recipient, transfer_in_public_from_call_interface); + // Impersonate recipient to perform the call + env.impersonate(recipient); + // Transfer the NFT + env.call_public(transfer_in_public_from_call_interface); + + // Check the is recipient is the new public owner + utils::assert_owns_public_nft(env, nft_contract_address, recipient, token_id); +} + +#[test(should_fail_with="invalid nonce")] +unconstrained fn transfer_in_public_failure_on_behalf_of_self_non_zero_nonce() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough. + // The authwit check is in the beginning so we don't need to waste time on minting the NFT and transferring + // it to private.. + let (env, nft_contract_address, sender, recipient) = utils::setup(/* with_account_contracts */ false); + + // We set random value for the token_id as the nonce check is before we use the value. + let token_id = unsafe_rand(); + + // Try to transfer the NFT + env.call_public(NFT::at(nft_contract_address).transfer_in_public(sender, recipient, token_id, unsafe_rand())); +} + +#[test(should_fail_with="invalid owner")] +unconstrained fn transfer_in_public_non_existent_nft() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, sender, recipient) = utils::setup(/* with_account_contracts */ false); + + // Try to transfer the NFT + let token_id = 612; + env.call_public(NFT::at(nft_contract_address).transfer_in_public(sender, recipient, token_id, 0)); +} + +// Not checking error message here with should_fail_with because noir panics when I try doing that. +#[test] +unconstrained fn transfer_in_public_failure_on_behalf_of_other_without_approval() { + // Setup with account contracts. Slower since we actually deploy them, but needed for authwits. + let (env, nft_contract_address, sender, recipient, token_id) = utils::setup_and_mint(/* with_account_contracts */ true); + + // Impersonate recipient to perform the call + env.impersonate(recipient); + // Try to transfer tokens + env.assert_public_call_fails(NFT::at(nft_contract_address).transfer_in_public(sender, recipient, token_id, 1)); + // Check the sender stayed the public owner + utils::assert_owns_public_nft(env, nft_contract_address, sender, token_id); +} + +// Not checking error message here with should_fail_with because noir panics when I try doing that. +#[test] +unconstrained fn transfer_in_public_failure_on_behalf_of_other_wrong_caller() { + // Setup with account contracts. Slower since we actually deploy them, but needed for authwits. + let (env, nft_contract_address, sender, recipient, token_id) = utils::setup_and_mint(/* with_account_contracts */ true); + let transfer_in_public_from_call_interface = NFT::at(nft_contract_address).transfer_in_public(sender, recipient, token_id, 1); + authwit_cheatcodes::add_public_authwit_from_call_interface(sender, sender, transfer_in_public_from_call_interface); + // Impersonate recipient to perform the call + env.impersonate(recipient); + // Try to transfer tokens + env.assert_public_call_fails(transfer_in_public_from_call_interface); + // Check the sender stayed the public owner + utils::assert_owns_public_nft(env, nft_contract_address, sender, token_id); +} diff --git a/noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_to_private.nr b/noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_to_private.nr new file mode 100644 index 00000000000..c64a5d247da --- /dev/null +++ b/noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_to_private.nr @@ -0,0 +1,130 @@ +use crate::test::utils; +use dep::aztec::{ + hash::pedersen_hash, keys::getters::get_current_public_keys, prelude::{AztecAddress, NoteHeader}, + oracle::unsafe_rand::unsafe_rand, protocol_types::storage::map::derive_storage_slot_in_map +}; +use crate::{types::nft_note::NFTNote, NFT}; + +#[test] +unconstrained fn transfer_to_private_to_self() { + // The transfer to private to self is done in `utils::setup_mint_and_transfer_to_private` and for this reason + // in this test we just call it and check the outcome. + + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, user, _, token_id) = utils::setup_mint_and_transfer_to_private(/* with_account_contracts */ false); + + // User should have the note in their private nfts + utils::assert_owns_private_nft(nft_contract_address, user, token_id); + + // Since the NFT was sent to private, the public owner should be zero address + utils::assert_owns_public_nft(env, nft_contract_address, AztecAddress::zero(), token_id); +} + +#[test] +unconstrained fn transfer_to_private_to_a_different_account() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, sender, recipient, token_id) = utils::setup_and_mint(/* with_account_contracts */ false); + + let note_randomness = unsafe_rand(); + let transient_storage_slot_randomness = unsafe_rand(); + // Sender will be the msg_sender/transfer_preparer in prepare_transfer_to_private + let transfer_preparer_storage_slot_commitment = pedersen_hash( + [sender.to_field(), transient_storage_slot_randomness], + NFT::TRANSIENT_STORAGE_SLOT_PEDERSEN_INDEX + ); + + // We prepare the transfer + env.call_private_void( + NFT::at(nft_contract_address).prepare_transfer_to_private( + sender, + recipient, + note_randomness, + transient_storage_slot_randomness + ) + ); + + // Finalize the transfer of the NFT + env.call_public( + NFT::at(nft_contract_address).finalize_transfer_to_private(token_id, transfer_preparer_storage_slot_commitment) + ); + + // Store the finalized note in the cache + let mut context = env.private(); + let recipient_npk_m_hash = get_current_public_keys(&mut context, recipient).npk_m.hash(); + let private_nfts_recipient_slot = derive_storage_slot_in_map(NFT::storage().private_nfts.slot, recipient); + + env.store_note_in_cache( + &mut NFTNote { token_id, npk_m_hash: recipient_npk_m_hash, randomness: note_randomness, header: NoteHeader::empty() }, + private_nfts_recipient_slot, + nft_contract_address + ); + + // Recipient should have the note in their private nfts + utils::assert_owns_private_nft(nft_contract_address, recipient, token_id); + + // Since the NFT got transferred to private public owner should be zero address + utils::assert_owns_public_nft(env, nft_contract_address, AztecAddress::zero(), token_id); +} + +#[test(should_fail_with="transfer not prepared")] +unconstrained fn transfer_to_private_to_self_transfer_not_prepared() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, _, _, token_id) = utils::setup_and_mint(/* with_account_contracts */ false); + + // Transfer was not prepared so we can use random value for the commitment + let transfer_preparer_storage_slot_commitment = unsafe_rand(); + + // Try finalizing the transfer without preparing it + env.call_public( + NFT::at(nft_contract_address).finalize_transfer_to_private(token_id, transfer_preparer_storage_slot_commitment) + ); +} + +#[test(should_fail_with="transfer not prepared")] +unconstrained fn transfer_to_private_finalizing_from_incorrect_sender() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, incorrect_sender, recipient, token_id) = utils::setup_and_mint(/* with_account_contracts */ false); + + let correct_sender = AztecAddress::from_field(9); + + let note_randomness = unsafe_rand(); + let transient_storage_slot_randomness = unsafe_rand(); + // Sender will be the msg_sender/transfer_preparer in prepare_transfer_to_private + let transfer_preparer_storage_slot_commitment = pedersen_hash( + [correct_sender.to_field(), transient_storage_slot_randomness], + NFT::TRANSIENT_STORAGE_SLOT_PEDERSEN_INDEX + ); + + // We prepare the transfer + env.call_private_void( + NFT::at(nft_contract_address).prepare_transfer_to_private( + correct_sender, + recipient, + note_randomness, + transient_storage_slot_randomness + ) + ); + + // We impersonate incorrect sender and try to finalize the transfer of the NFT. The incorrect sender owns the NFT + // but tries to consume a prepared transfer not belonging to him. For this reason the test should fail with + // "transfer not prepared". + env.impersonate(incorrect_sender); + env.call_public( + NFT::at(nft_contract_address).finalize_transfer_to_private(token_id, transfer_preparer_storage_slot_commitment) + ); +} + +#[test(should_fail_with="invalid NFT owner")] +unconstrained fn transfer_to_private_failure_not_an_owner() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, _, not_owner, token_id) = utils::setup_and_mint(/* with_account_contracts */ false); + + // We set random value for the commitment as the NFT owner check is before we use the value + let transfer_preparer_storage_slot_commitment = unsafe_rand(); + + // Try transferring someone else's public NFT + env.impersonate(not_owner); + env.call_public( + NFT::at(nft_contract_address).finalize_transfer_to_private(token_id, transfer_preparer_storage_slot_commitment) + ); +} diff --git a/noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_to_public.nr b/noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_to_public.nr new file mode 100644 index 00000000000..ca24227e785 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/nft_contract/src/test/transfer_to_public.nr @@ -0,0 +1,80 @@ +use crate::test::utils; +use dep::aztec::oracle::unsafe_rand::unsafe_rand; +use dep::authwit::cheatcodes as authwit_cheatcodes; +use crate::NFT; + +#[test] +unconstrained fn transfer_to_public() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, sender, recipient, token_id) = utils::setup_mint_and_transfer_to_private(/* with_account_contracts */ false); + + env.call_private_void(NFT::at(nft_contract_address).transfer_to_public(sender, recipient, token_id, 0)); + + // Recipient should be the public owner + utils::assert_owns_public_nft(env, nft_contract_address, recipient, token_id); +} + +#[test] +unconstrained fn transfer_to_public_to_self() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, user, _, token_id) = utils::setup_mint_and_transfer_to_private(/* with_account_contracts */ false); + + env.call_private_void(NFT::at(nft_contract_address).transfer_to_public(user, user, token_id, 0)); + + // Check the user stayed the public owner + utils::assert_owns_public_nft(env, nft_contract_address, user, token_id); +} + +#[test] +unconstrained fn transfer_to_public_on_behalf_of_other() { + let (env, nft_contract_address, sender, recipient, token_id) = utils::setup_mint_and_transfer_to_private(/* with_account_contracts */ true); + + let transfer_to_public_call_interface = NFT::at(nft_contract_address).transfer_to_public(sender, recipient, token_id, 0); + authwit_cheatcodes::add_private_authwit_from_call_interface(sender, recipient, transfer_to_public_call_interface); + // Impersonate recipient + env.impersonate(recipient); + // transfer_to_public the NFT + env.call_private_void(transfer_to_public_call_interface); + + // Recipient should be the public owner + utils::assert_owns_public_nft(env, nft_contract_address, recipient, token_id); +} + +#[test(should_fail_with="NFT not found when transferring to public")] +unconstrained fn transfer_to_public_failure_not_an_owner() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, _, not_owner, token_id) = utils::setup_mint_and_transfer_to_private(/* with_account_contracts */ false); + + env.impersonate(not_owner); + env.call_private_void(NFT::at(nft_contract_address).transfer_to_public(not_owner, not_owner, token_id, 0)); +} + +#[test(should_fail_with="invalid nonce")] +unconstrained fn transfer_to_public_failure_on_behalf_of_self_non_zero_nonce() { + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, nft_contract_address, user, _, token_id) = utils::setup_mint_and_transfer_to_private(/* with_account_contracts */ false); + + env.call_private_void(NFT::at(nft_contract_address).transfer_to_public(user, user, token_id, unsafe_rand())); +} + +#[test(should_fail_with="Authorization not found for message hash")] +unconstrained fn transfer_to_public_failure_on_behalf_of_other_invalid_designated_caller() { + let (env, nft_contract_address, sender, recipient, token_id) = utils::setup_mint_and_transfer_to_private(/* with_account_contracts */ true); + + let transfer_to_public_call_interface = NFT::at(nft_contract_address).transfer_to_public(sender, recipient, token_id, 0); + authwit_cheatcodes::add_private_authwit_from_call_interface(sender, sender, transfer_to_public_call_interface); + // Impersonate recipient + env.impersonate(recipient); + // transfer_to_public the NFT + env.call_private_void(transfer_to_public_call_interface); +} + +#[test(should_fail_with="Authorization not found for message hash")] +unconstrained fn transfer_to_public_failure_on_behalf_of_other_no_approval() { + let (env, nft_contract_address, sender, recipient, token_id) = utils::setup_mint_and_transfer_to_private(/* with_account_contracts */ true); + + // Impersonate recipient + env.impersonate(recipient); + // transfer_to_public the NFT + env.call_private_void(NFT::at(nft_contract_address).transfer_to_public(sender, recipient, token_id, 0)); +} diff --git a/noir-projects/noir-contracts/contracts/nft_contract/src/test/utils.nr b/noir-projects/noir-contracts/contracts/nft_contract/src/test/utils.nr new file mode 100644 index 00000000000..ad2e3e8eafd --- /dev/null +++ b/noir-projects/noir-contracts/contracts/nft_contract/src/test/utils.nr @@ -0,0 +1,134 @@ +use dep::aztec::{ + hash::pedersen_hash, keys::getters::get_current_public_keys, prelude::{AztecAddress, NoteHeader}, + test::helpers::{cheatcodes, test_environment::TestEnvironment}, + protocol_types::storage::map::derive_storage_slot_in_map, + oracle::{execution::{get_block_number, get_contract_address}, unsafe_rand::unsafe_rand, storage::storage_read} +}; +use crate::{types::nft_note::NFTNote, NFT}; + +pub fn setup(with_account_contracts: bool) -> (&mut TestEnvironment, AztecAddress, AztecAddress, AztecAddress) { + // Setup env, generate keys + let mut env = TestEnvironment::new(); + let (owner, recipient) = if with_account_contracts { + let owner = env.create_account_contract(1); + let recipient = env.create_account_contract(2); + // Deploy canonical auth registry + let _auth_registry = env.deploy("./@auth_registry_contract", "AuthRegistry").without_initializer(); + (owner, recipient) + } else { + let owner = env.create_account(); + let recipient = env.create_account(); + (owner, recipient) + }; + + // Start the test in the account contract address + env.impersonate(owner); + + // Deploy token contract + let initializer_call_interface = NFT::interface().constructor( + owner, + "TestNFT000000000000000000000000", + "TN00000000000000000000000000000" + ); + let nft_contract = env.deploy_self("NFT").with_public_initializer(initializer_call_interface); + let nft_contract_address = nft_contract.to_address(); + env.advance_block_by(1); + (&mut env, nft_contract_address, owner, recipient) +} + +pub fn setup_and_mint(with_account_contracts: bool) -> (&mut TestEnvironment, AztecAddress, AztecAddress, AztecAddress, Field) { + // Setup + let (env, nft_contract_address, owner, recipient) = setup(with_account_contracts); + let minted_token_id = 615; + + let mint_public_call_interface = NFT::at(nft_contract_address).mint(owner, minted_token_id); + env.call_public(mint_public_call_interface); + + (env, nft_contract_address, owner, recipient, minted_token_id) +} + +pub fn setup_mint_and_transfer_to_private(with_account_contracts: bool) -> (&mut TestEnvironment, AztecAddress, AztecAddress, AztecAddress, Field) { + let (env, nft_contract_address, owner, recipient, minted_token_id) = setup_and_mint(with_account_contracts); + + let note_randomness = unsafe_rand(); + let transient_storage_slot_randomness = unsafe_rand(); + let transfer_preparer_storage_slot_commitment = pedersen_hash( + [owner.to_field(), transient_storage_slot_randomness], + NFT::TRANSIENT_STORAGE_SLOT_PEDERSEN_INDEX + ); + + // We prepare the transfer with user being both the sender and the recipient (classical "shield" flow) + let prepare_transfer_to_private_call_interface = NFT::at(nft_contract_address).prepare_transfer_to_private( + owner, + owner, + note_randomness, + transient_storage_slot_randomness + ); + env.call_private_void(prepare_transfer_to_private_call_interface); + + // Finalize the transfer of the NFT + let finalize_transfer_to_private_call_interface = NFT::at(nft_contract_address).finalize_transfer_to_private(minted_token_id, transfer_preparer_storage_slot_commitment); + env.call_public(finalize_transfer_to_private_call_interface); + + // Store the finalized note in the cache + let mut context = env.private(); + let owner_npk_m_hash = get_current_public_keys(&mut context, owner).npk_m.hash(); + let private_nfts_owner_slot = derive_storage_slot_in_map(NFT::storage().private_nfts.slot, owner); + + env.store_note_in_cache( + &mut NFTNote { + token_id: minted_token_id, + npk_m_hash: owner_npk_m_hash, + randomness: note_randomness, + header: NoteHeader::empty() + }, + private_nfts_owner_slot, + nft_contract_address + ); + + (env, nft_contract_address, owner, recipient, minted_token_id) +} + +pub fn get_nft_exists(nft_contract_address: AztecAddress, token_id: Field) -> bool { + let current_contract_address = get_contract_address(); + cheatcodes::set_contract_address(nft_contract_address); + let block_number = get_block_number(); + + let nft_exists_slot = NFT::storage().nft_exists.slot; + let nft_slot = derive_storage_slot_in_map(nft_exists_slot, token_id); + let exists: bool = storage_read(nft_contract_address, nft_slot, block_number); + cheatcodes::set_contract_address(current_contract_address); + + exists +} + +pub fn assert_owns_public_nft( + env: &mut TestEnvironment, + nft_contract_address: AztecAddress, + owner: AztecAddress, + token_id: Field +) { + let owner_of_interface = NFT::at(nft_contract_address).owner_of(token_id); + let obtained_owner = env.call_public(owner_of_interface); + + assert(owner == obtained_owner, "Incorrect NFT owner"); +} + +pub fn assert_owns_private_nft(nft_contract_address: AztecAddress, owner: AztecAddress, token_id: Field) { + let current_contract_address = get_contract_address(); + cheatcodes::set_contract_address(nft_contract_address); + + // Direct call to unconstrained + let (private_nfts, _) = NFT::get_private_nfts(owner, 0); + + let mut nft_found = false; + for obtained_token_id in private_nfts { + if obtained_token_id == token_id { + nft_found = true; + } + } + + cheatcodes::set_contract_address(current_contract_address); + + assert(nft_found, "NFT not found in private nfts"); +} diff --git a/noir-projects/noir-contracts/contracts/nft_contract/src/types.nr b/noir-projects/noir-contracts/contracts/nft_contract/src/types.nr new file mode 100644 index 00000000000..26ed97fbe8a --- /dev/null +++ b/noir-projects/noir-contracts/contracts/nft_contract/src/types.nr @@ -0,0 +1 @@ +mod nft_note; diff --git a/noir-projects/noir-contracts/contracts/nft_contract/src/types/nft_note.nr b/noir-projects/noir-contracts/contracts/nft_contract/src/types/nft_note.nr new file mode 100644 index 00000000000..cce6812150b --- /dev/null +++ b/noir-projects/noir-contracts/contracts/nft_contract/src/types/nft_note.nr @@ -0,0 +1,131 @@ +use dep::aztec::{ + generators::{Ga1 as G_tid, Ga2 as G_npk, Ga3 as G_rnd, G_slot}, + note::utils::compute_note_hash_for_nullify, keys::getters::get_nsk_app, + oracle::unsafe_rand::unsafe_rand, + prelude::{AztecAddress, NoteInterface, NoteHeader, PrivateContext}, + protocol_types::{ + constants::GENERATOR_INDEX__NOTE_NULLIFIER, point::{Point, POINT_LENGTH}, + hash::poseidon2_hash_with_separator, traits::{Empty, Eq, Deserialize, Serialize} +} +}; +use std::{embedded_curve_ops::multi_scalar_mul, hash::from_field_unsafe}; + +global NFT_NOTE_LEN: Field = 3; +// NFT_NOTE_LEN * 32 + 32(storage_slot as bytes) + 32(note_type_id as bytes) +global NFT_NOTE_BYTES_LEN: Field = 3 * 32 + 64; + +#[aztec(note)] +struct NFTNote { + // ID of the token + token_id: Field, + // The nullifying public key hash is used with the nsk_app to ensure that the note can be privately spent. + npk_m_hash: Field, + // Randomness of the note to hide its contents + randomness: Field, +} + +impl NoteInterface for NFTNote { + fn compute_nullifier(self, context: &mut PrivateContext, note_hash_for_nullify: Field) -> Field { + let secret = context.request_nsk_app(self.npk_m_hash); + poseidon2_hash_with_separator( + [ + note_hash_for_nullify, + secret + ], + GENERATOR_INDEX__NOTE_NULLIFIER as Field + ) + } + + fn compute_nullifier_without_context(self) -> Field { + let note_hash_for_nullify = compute_note_hash_for_nullify(self); + let secret = get_nsk_app(self.npk_m_hash); + poseidon2_hash_with_separator( + [ + note_hash_for_nullify, + secret + ], + GENERATOR_INDEX__NOTE_NULLIFIER as Field + ) + } + + fn compute_note_hiding_point(self) -> Point { + // We use the unsafe version because `multi_scalar_mul` will constrain the scalars. + let token_id_scalar = from_field_unsafe(self.token_id); + let npk_m_hash_scalar = from_field_unsafe(self.npk_m_hash); + let randomness_scalar = from_field_unsafe(self.randomness); + let slot_scalar = from_field_unsafe(self.header.storage_slot); + + multi_scalar_mul( + [G_tid, G_npk, G_rnd, G_slot], + [token_id_scalar, npk_m_hash_scalar, randomness_scalar, slot_scalar] + ) + } +} + +impl NFTNote { + pub fn new(token_id: Field, npk_m_hash: Field) -> Self { + let randomness = unsafe { + unsafe_rand() + }; + NFTNote { token_id, npk_m_hash, randomness, header: NoteHeader::empty() } + } +} + +impl Eq for NFTNote { + fn eq(self, other: Self) -> bool { + (self.token_id == other.token_id) + & (self.npk_m_hash == other.npk_m_hash) + & (self.randomness == other.randomness) + } +} + +// TODO(#8290): Auto-generate this +struct NFTNoteHidingPoint { + inner: Point +} + +impl NFTNoteHidingPoint { + // TODO(#8238): Remove the randomness argument below + fn new(npk_m_hash: Field, storage_slot: Field, randomness: Field) -> Self { + // TODO(#8238): And uncomment this + // let randomness = unsafe { + // unsafe_rand() + // }; + let note = NFTNote { + header: NoteHeader { contract_address: AztecAddress::zero(), nonce: 0, storage_slot, note_hash_counter: 0 }, + token_id: 0, + npk_m_hash, + randomness + }; + Self { inner: note.compute_note_hiding_point() } + } + + fn finalize(self, token_id: Field) -> Field { + let finalized_hiding_point = multi_scalar_mul([G_tid], [from_field_unsafe(token_id)]) + self.inner; + finalized_hiding_point.x + } +} + +impl Serialize for NFTNoteHidingPoint { + fn serialize(self) -> [Field; POINT_LENGTH] { + self.inner.serialize() + } +} + +impl Deserialize for NFTNoteHidingPoint { + fn deserialize(serialized: [Field; POINT_LENGTH]) -> NFTNoteHidingPoint { + NFTNoteHidingPoint { inner: Point::deserialize(serialized) } + } +} + +impl Empty for NFTNoteHidingPoint { + fn empty() -> Self { + Self { inner: Point::empty() } + } +} + +impl Eq for NFTNoteHidingPoint { + fn eq(self, other: Self) -> bool { + self.inner == other.inner + } +} diff --git a/noir-projects/noir-contracts/contracts/token_bridge_contract/src/main.nr b/noir-projects/noir-contracts/contracts/token_bridge_contract/src/main.nr index 06de11e96cb..d88eca6b200 100644 --- a/noir-projects/noir-contracts/contracts/token_bridge_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/token_bridge_contract/src/main.nr @@ -6,7 +6,7 @@ // Bridge has to be set as a minter on the token before it can be used contract TokenBridge { - use dep::aztec::prelude::{FunctionSelector, AztecAddress, EthAddress, PublicMutable, SharedImmutable}; + use dep::aztec::prelude::{AztecAddress, EthAddress, PublicMutable, SharedImmutable}; use dep::token_portal_content_hash_lib::{get_mint_public_content_hash, get_mint_private_content_hash, get_withdraw_content_hash}; @@ -80,6 +80,7 @@ contract TokenBridge { // docs:start:claim_private // Consumes a L1->L2 message and calls the token contract to mint the appropriate amount in private assets // User needs to call token.redeem_shield() to get the private assets + // TODO(#8416): Consider creating a truly private claim flow. #[aztec(private)] fn claim_private( secret_hash_for_redeeming_minted_notes: Field, // secret hash used to redeem minted notes at a later time. This enables anyone to call this function and mint tokens to a user on their behalf diff --git a/noir-projects/noir-contracts/contracts/token_contract/src/main.nr b/noir-projects/noir-contracts/contracts/token_contract/src/main.nr index e31b47382cc..a4e556c1b5b 100644 --- a/noir-projects/noir-contracts/contracts/token_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/token_contract/src/main.nr @@ -147,7 +147,7 @@ contract Token { // docs:start:admin #[aztec(public)] #[aztec(view)] - fn admin() -> Field { + fn get_admin() -> Field { storage.admin.read().to_field() } // docs:end:admin diff --git a/noir-projects/noir-contracts/contracts/token_contract/src/test/access_control.nr b/noir-projects/noir-contracts/contracts/token_contract/src/test/access_control.nr index 45a4ce6a295..510819e331c 100644 --- a/noir-projects/noir-contracts/contracts/token_contract/src/test/access_control.nr +++ b/noir-projects/noir-contracts/contracts/token_contract/src/test/access_control.nr @@ -11,7 +11,7 @@ unconstrained fn access_control() { env.call_public(set_admin_call_interface); // Check it worked - let get_admin_call_interface = Token::at(token_contract_address).admin(); + let get_admin_call_interface = Token::at(token_contract_address).get_admin(); let admin = env.call_public(get_admin_call_interface); assert(admin == recipient.to_field()); diff --git a/noir-projects/noir-contracts/contracts/token_contract/src/test/refunds.nr b/noir-projects/noir-contracts/contracts/token_contract/src/test/refunds.nr index 8bd9b5763b8..3d286a2ff52 100644 --- a/noir-projects/noir-contracts/contracts/token_contract/src/test/refunds.nr +++ b/noir-projects/noir-contracts/contracts/token_contract/src/test/refunds.nr @@ -1,7 +1,6 @@ use crate::{test::utils, Token, types::token_note::TokenNote}; use dep::aztec::{ - test::helpers::cheatcodes, oracle::unsafe_rand::unsafe_rand, hash::compute_secret_hash, prelude::NoteHeader, protocol_types::storage::map::derive_storage_slot_in_map, keys::getters::get_current_public_keys }; diff --git a/noir-projects/noir-contracts/contracts/token_contract/src/test/shielding.nr b/noir-projects/noir-contracts/contracts/token_contract/src/test/shielding.nr index a1f9212d866..da91b64ca6f 100644 --- a/noir-projects/noir-contracts/contracts/token_contract/src/test/shielding.nr +++ b/noir-projects/noir-contracts/contracts/token_contract/src/test/shielding.nr @@ -1,5 +1,5 @@ use crate::test::utils; -use dep::aztec::{test::helpers::cheatcodes, oracle::unsafe_rand::unsafe_rand, hash::compute_secret_hash}; +use dep::aztec::{oracle::unsafe_rand::unsafe_rand, hash::compute_secret_hash}; use dep::authwit::cheatcodes as authwit_cheatcodes; use crate::{types::transparent_note::TransparentNote, Token}; diff --git a/noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_private.nr b/noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_private.nr index 0b4c9cb72d6..08f22fd47f1 100644 --- a/noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_private.nr +++ b/noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_private.nr @@ -80,8 +80,8 @@ unconstrained fn transfer_private_failure_more_than_balance() { // docs:start:fail_with_message #[test(should_fail_with="invalid nonce")] unconstrained fn transfer_private_failure_on_behalf_of_self_non_zero_nonce() { - // Setup with account contracts. Slower since we actually deploy them, but needed for authwits. - let (env, token_contract_address, owner, recipient, _) = utils::setup_and_mint(/* with_account_contracts */ true); + // Setup without account contracts. We are not using authwits here, so dummy accounts are enough + let (env, token_contract_address, owner, recipient, _) = utils::setup_and_mint(/* with_account_contracts */ false); // Add authwit let transfer_amount = 1000; let transfer_private_from_call_interface = Token::at(token_contract_address).transfer_from(owner, recipient, transfer_amount, 1); @@ -104,10 +104,10 @@ unconstrained fn transfer_private_failure_on_behalf_of_more_than_balance() { env.call_private_void(transfer_private_from_call_interface); } -#[test(should_fail)] +#[test(should_fail_with="Authorization not found for message hash")] unconstrained fn transfer_private_failure_on_behalf_of_other_without_approval() { // Setup with account contracts. Slower since we actually deploy them, but needed for authwits. - let (env, token_contract_address, owner, recipient, mint_amount) = utils::setup_and_mint(/* with_account_contracts */ true); + let (env, token_contract_address, owner, recipient, _) = utils::setup_and_mint(/* with_account_contracts */ true); // Add authwit let transfer_amount = 1000; let transfer_private_from_call_interface = Token::at(token_contract_address).transfer_from(owner, recipient, transfer_amount, 1); @@ -115,15 +115,12 @@ unconstrained fn transfer_private_failure_on_behalf_of_other_without_approval() env.impersonate(recipient); // Transfer tokens env.call_private_void(transfer_private_from_call_interface); - // Check balances - utils::check_private_balance(token_contract_address, owner, mint_amount - transfer_amount); - utils::check_private_balance(token_contract_address, recipient, transfer_amount); } -#[test(should_fail)] +#[test(should_fail_with="Authorization not found for message hash")] unconstrained fn transfer_private_failure_on_behalf_of_other_wrong_caller() { // Setup with account contracts. Slower since we actually deploy them, but needed for authwits. - let (env, token_contract_address, owner, recipient, mint_amount) = utils::setup_and_mint(/* with_account_contracts */ true); + let (env, token_contract_address, owner, recipient, _) = utils::setup_and_mint(/* with_account_contracts */ true); // Add authwit let transfer_amount = 1000; let transfer_private_from_call_interface = Token::at(token_contract_address).transfer_from(owner, recipient, transfer_amount, 1); @@ -132,7 +129,4 @@ unconstrained fn transfer_private_failure_on_behalf_of_other_wrong_caller() { env.impersonate(recipient); // Transfer tokens env.call_private_void(transfer_private_from_call_interface); - // Check balances - utils::check_private_balance(token_contract_address, owner, mint_amount - transfer_amount); - utils::check_private_balance(token_contract_address, recipient, transfer_amount); } diff --git a/noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_public.nr b/noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_public.nr index aae666f6bd3..65b1cbf612b 100644 --- a/noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_public.nr +++ b/noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_public.nr @@ -1,5 +1,5 @@ use crate::test::utils; -use dep::aztec::{test::helpers::cheatcodes, oracle::unsafe_rand::unsafe_rand}; +use dep::aztec::oracle::unsafe_rand::unsafe_rand; use dep::authwit::cheatcodes as authwit_cheatcodes; use crate::Token; @@ -63,20 +63,18 @@ unconstrained fn public_transfer_failure_more_than_balance() { utils::check_public_balance(token_contract_address, owner, mint_amount); } -#[test] +#[test(should_fail_with="invalid nonce")] unconstrained fn public_transfer_failure_on_behalf_of_self_non_zero_nonce() { // Setup without account contracts. We are not using authwits here, so dummy accounts are enough - let (env, token_contract_address, owner, recipient, mint_amount) = utils::setup_and_mint(/* with_account_contracts */ true); + let (env, token_contract_address, owner, recipient, mint_amount) = utils::setup_and_mint(/* with_account_contracts */ false); // Transfer tokens let transfer_amount = mint_amount / 10; let public_transfer_call_interface = Token::at(token_contract_address).transfer_public(owner, recipient, transfer_amount, unsafe_rand()); // Try to transfer tokens - env.assert_public_call_fails(public_transfer_call_interface); - - // Check balances - utils::check_public_balance(token_contract_address, owner, mint_amount); + env.call_public(public_transfer_call_interface); } +// Not checking error message here with should_fail_with because noir panics when I try doing that. #[test] unconstrained fn public_transfer_failure_on_behalf_of_other_without_approval() { // Setup with account contracts. Slower since we actually deploy them, but needed for authwits. @@ -92,7 +90,7 @@ unconstrained fn public_transfer_failure_on_behalf_of_other_without_approval() { utils::check_public_balance(token_contract_address, recipient, 0); } -#[test] +#[test(should_fail_with="attempt to subtract with underflow")] unconstrained fn public_transfer_failure_on_behalf_of_other_more_than_balance() { // Setup with account contracts. Slower since we actually deploy them, but needed for authwits. let (env, token_contract_address, owner, recipient, mint_amount) = utils::setup_and_mint(/* with_account_contracts */ true); @@ -104,12 +102,10 @@ unconstrained fn public_transfer_failure_on_behalf_of_other_more_than_balance() // Impersonate recipient to perform the call env.impersonate(recipient); // Try to transfer tokens - env.assert_public_call_fails(public_transfer_from_call_interface); - // Check balances - utils::check_public_balance(token_contract_address, owner, mint_amount); - utils::check_public_balance(token_contract_address, recipient, 0); + env.call_public(public_transfer_from_call_interface); } +// Not checking error message here with should_fail_with because noir panics when I try doing that. #[test] unconstrained fn public_transfer_failure_on_behalf_of_other_wrong_caller() { // Setup with account contracts. Slower since we actually deploy them, but needed for authwits. diff --git a/noir-projects/noir-contracts/contracts/token_contract/src/test/unshielding.nr b/noir-projects/noir-contracts/contracts/token_contract/src/test/unshielding.nr index d127046d382..061cf29e686 100644 --- a/noir-projects/noir-contracts/contracts/token_contract/src/test/unshielding.nr +++ b/noir-projects/noir-contracts/contracts/token_contract/src/test/unshielding.nr @@ -1,5 +1,5 @@ use crate::test::utils; -use dep::aztec::{oracle::unsafe_rand::unsafe_rand, test::helpers::cheatcodes}; +use dep::aztec::oracle::unsafe_rand::unsafe_rand; use dep::authwit::cheatcodes as authwit_cheatcodes; use crate::Token; @@ -63,7 +63,7 @@ unconstrained fn unshield_failure_on_behalf_of_other_more_than_balance() { env.call_private_void(unshield_call_interface); } -#[test(should_fail)] +#[test(should_fail_with="Authorization not found for message hash")] unconstrained fn unshield_failure_on_behalf_of_other_invalid_designated_caller() { let (env, token_contract_address, owner, recipient, mint_amount) = utils::setup_and_mint(/* with_account_contracts */ true); @@ -76,7 +76,7 @@ unconstrained fn unshield_failure_on_behalf_of_other_invalid_designated_caller() env.call_private_void(unshield_call_interface); } -#[test(should_fail)] +#[test(should_fail_with="Authorization not found for message hash")] unconstrained fn unshield_failure_on_behalf_of_other_no_approval() { let (env, token_contract_address, owner, recipient, mint_amount) = utils::setup_and_mint(/* with_account_contracts */ true); diff --git a/noir-projects/noir-contracts/contracts/token_contract/src/types/token_note.nr b/noir-projects/noir-contracts/contracts/token_contract/src/types/token_note.nr index 2163c254a4e..d9e5c9c7ee4 100644 --- a/noir-projects/noir-contracts/contracts/token_contract/src/types/token_note.nr +++ b/noir-projects/noir-contracts/contracts/token_contract/src/types/token_note.nr @@ -73,13 +73,14 @@ impl NoteInterface for TokenNote { } impl TokenNote { - // TODO: Merge this func with `compute_note_hiding_point`. I (benesjan) didn't do it in the initial PR to not have + // TODO(#8290): Merge this func with `compute_note_hiding_point`. I (benesjan) didn't do it in the initial PR to not have // to modify macros and all the related funcs in it. fn to_note_hiding_point(self) -> TokenNoteHidingPoint { TokenNoteHidingPoint::new(self.compute_note_hiding_point()) } } +// TODO(#8290): Auto-generate this struct TokenNoteHidingPoint { inner: Point } diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/abis/private_circuit_public_inputs.nr b/noir-projects/noir-protocol-circuits/crates/types/src/abis/private_circuit_public_inputs.nr index a1565d85df3..535a3e9ccb8 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/abis/private_circuit_public_inputs.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/abis/private_circuit_public_inputs.nr @@ -48,6 +48,7 @@ impl PrivateCircuitPublicInputsArrayLengths { } } +// Public inputs to private app circuit. struct PrivateCircuitPublicInputs { call_context: CallContext, diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/abis/public_circuit_public_inputs.nr b/noir-projects/noir-protocol-circuits/crates/types/src/abis/public_circuit_public_inputs.nr index 8721797316c..5099940c016 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/abis/public_circuit_public_inputs.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/abis/public_circuit_public_inputs.nr @@ -18,6 +18,7 @@ use crate::{ utils::reader::Reader }; +// Public inputs to public app circuit. struct PublicCircuitPublicInputs { call_context: CallContext, diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/point.nr b/noir-projects/noir-protocol-circuits/crates/types/src/point.nr index d7aea653c61..f3737136951 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/point.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/point.nr @@ -1,5 +1,5 @@ pub use dep::std::embedded_curve_ops::EmbeddedCurvePoint as Point; -use crate::{traits::{Empty, Hash, Serialize}, hash::poseidon2_hash}; +use crate::{traits::{Deserialize, Empty, Hash, Serialize}, hash::poseidon2_hash}; global POINT_LENGTH: Field = 3; @@ -22,3 +22,9 @@ impl Empty for Point { Point { x: 0, y: 0, is_infinite: false } } } + +impl Deserialize for Point { + fn deserialize(serialized: [Field; POINT_LENGTH]) -> Point { + Point { x: serialized[0], y: serialized[1], is_infinite: serialized[2] as bool } + } +} diff --git a/yarn-project/end-to-end/Earthfile b/yarn-project/end-to-end/Earthfile index 8e8c05c9377..e0a35d9fcfd 100644 --- a/yarn-project/end-to-end/Earthfile +++ b/yarn-project/end-to-end/Earthfile @@ -116,6 +116,9 @@ e2e-l1-with-wall-time: e2e-2-pxes: DO +E2E_TEST --test=./src/e2e_2_pxes.test.ts +e2e-nft: + DO +E2E_TEST --test=./src/e2e_nft.test.ts + e2e-prover-full: DO +E2E_TEST --test=./src/e2e_prover/full --hardware_concurrency=${HARDWARE_CONCURRENCY:-32} diff --git a/yarn-project/end-to-end/src/e2e_nft.test.ts b/yarn-project/end-to-end/src/e2e_nft.test.ts new file mode 100644 index 00000000000..36cba60c70b --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_nft.test.ts @@ -0,0 +1,164 @@ +import { type AccountWallet, AztecAddress, BatchCall, ExtendedNote, Fr, Note } from '@aztec/aztec.js'; +import { deriveStorageSlotInMap } from '@aztec/circuits.js/hash'; +import { pedersenHash } from '@aztec/foundation/crypto'; +import { NFTContract } from '@aztec/noir-contracts.js'; + +import { jest } from '@jest/globals'; + +import { setup } from './fixtures/utils.js'; + +const TIMEOUT = 120_000; + +// This is a very simple test checking only the happy path. More complete tests of the NFT are implemented with TXE. +// This test is only kept around to check that public data writes are squashed as expected. +describe('NFT', () => { + jest.setTimeout(TIMEOUT); + + let teardown: () => Promise; + + let adminWallet: AccountWallet; + let minterWallet: AccountWallet; + let user1Wallet: AccountWallet; + let user2Wallet: AccountWallet; + + let nftContractAddress: AztecAddress; + + // Arbitrary token id + const TOKEN_ID = Fr.random().toBigInt(); + const TRANSIENT_STORAGE_SLOT_PEDERSEN_INDEX = 3; + + beforeAll(async () => { + let wallets: AccountWallet[]; + ({ teardown, wallets } = await setup(4)); + [adminWallet, minterWallet, user1Wallet, user2Wallet] = wallets; + + const nftContract = await NFTContract.deploy(adminWallet, adminWallet.getAddress(), 'FROG', 'FRG') + .send() + .deployed(); + nftContractAddress = nftContract.address; + }); + + afterAll(() => teardown()); + + // NOTE: This test is sequential and each test case depends on the previous one + it('sets minter', async () => { + const nftContractAsAdmin = await NFTContract.at(nftContractAddress, adminWallet); + + await nftContractAsAdmin.methods.set_minter(minterWallet.getAddress(), true).send().wait(); + const isMinterAMinter = await nftContractAsAdmin.methods.is_minter(minterWallet.getAddress()).simulate(); + expect(isMinterAMinter).toBe(true); + }); + + it('minter mints to a user', async () => { + const nftContractAsMinter = await NFTContract.at(nftContractAddress, minterWallet); + + await nftContractAsMinter.methods.mint(user1Wallet.getAddress(), TOKEN_ID).send().wait(); + + const ownerAfterMint = await nftContractAsMinter.methods.owner_of(TOKEN_ID).simulate(); + expect(ownerAfterMint).toEqual(user1Wallet.getAddress()); + }); + + it('transfers to private', async () => { + const nftContractAsUser1 = await NFTContract.at(nftContractAddress, user1Wallet); + + // In a simple "shield" flow the sender and recipient are the same. In the "uniswap swap to private" flow + // it would be the uniswap contract. + const recipient = user1Wallet.getAddress(); + const sender = recipient; + const noteRandomness = Fr.random(); + const transientStorageSlotRandomness = Fr.random(); + const transferPreparerStorageSlotCommitment = pedersenHash( + [user1Wallet.getAddress(), transientStorageSlotRandomness], + TRANSIENT_STORAGE_SLOT_PEDERSEN_INDEX, + ); + + const { txHash, debugInfo } = await new BatchCall(user1Wallet, [ + nftContractAsUser1.methods + .prepare_transfer_to_private(sender, recipient, noteRandomness, transientStorageSlotRandomness) + .request(), + nftContractAsUser1.methods + .finalize_transfer_to_private(TOKEN_ID, transferPreparerStorageSlotCommitment) + .request(), + ]) + .send() + .wait({ debug: true }); + + const publicOwnerAfter = await nftContractAsUser1.methods.owner_of(TOKEN_ID).simulate(); + expect(publicOwnerAfter).toEqual(AztecAddress.ZERO); + + // TODO(#8238): Since we don't yet have a partial note delivery we have to manually add it to PXE + const nftNote = new Note([ + new Fr(TOKEN_ID), + user1Wallet.getCompleteAddress().publicKeys.masterNullifierPublicKey.hash(), + noteRandomness, + ]); + + await user1Wallet.addNote( + new ExtendedNote( + nftNote, + user1Wallet.getAddress(), + nftContractAsUser1.address, + deriveStorageSlotInMap(NFTContract.storage.private_nfts.slot, user1Wallet.getAddress()), + NFTContract.notes.NFTNote.id, + txHash, + ), + ); + + // We should get 4 data writes setting values to 0 - 3 for note hiding point and 1 for public owner (we transfer + // to private so public owner is set to 0). Ideally we would have here only 1 data write as the 4 values change + // from zero to non-zero to zero in the tx and hence no write could be committed. This makes public writes + // squashing too expensive for transient storage. This however probably does not matter as I assume we will want + // to implement a real transient storage anyway. (Informed Leila about the potential optimization.) + const publicDataWritesValues = debugInfo!.publicDataWrites!.map(write => write.newValue.toBigInt()); + expect(publicDataWritesValues).toEqual([0n, 0n, 0n, 0n]); + }); + + it('transfers in private', async () => { + const nftContractAsUser1 = await NFTContract.at(nftContractAddress, user1Wallet); + + await nftContractAsUser1.methods + .transfer_in_private(user1Wallet.getAddress(), user2Wallet.getAddress(), TOKEN_ID, 0) + .send() + .wait(); + + const user1Nfts = await getPrivateNfts(user1Wallet.getAddress()); + expect(user1Nfts).toEqual([]); + + const user2Nfts = await getPrivateNfts(user2Wallet.getAddress()); + expect(user2Nfts).toEqual([TOKEN_ID]); + }); + + it('transfers to public', async () => { + const nftContractAsUser2 = await NFTContract.at(nftContractAddress, user2Wallet); + + await nftContractAsUser2.methods + .transfer_to_public(user2Wallet.getAddress(), user2Wallet.getAddress(), TOKEN_ID, 0) + .send() + .wait(); + + const publicOwnerAfter = await nftContractAsUser2.methods.owner_of(TOKEN_ID).simulate(); + expect(publicOwnerAfter).toEqual(user2Wallet.getAddress()); + }); + + it('transfers in public', async () => { + const nftContractAsUser2 = await NFTContract.at(nftContractAddress, user2Wallet); + + await nftContractAsUser2.methods + .transfer_in_public(user2Wallet.getAddress(), user1Wallet.getAddress(), TOKEN_ID, 0) + .send() + .wait(); + + const publicOwnerAfter = await nftContractAsUser2.methods.owner_of(TOKEN_ID).simulate(); + expect(publicOwnerAfter).toEqual(user1Wallet.getAddress()); + }); + + const getPrivateNfts = async (owner: AztecAddress) => { + const nftContractAsUser1 = await NFTContract.at(nftContractAddress, user1Wallet); + const [nfts, pageLimitReached] = await nftContractAsUser1.methods.get_private_nfts(owner, 0).simulate(); + if (pageLimitReached) { + throw new Error('Page limit reached and pagination not implemented in test'); + } + // We prune placeholder values + return nfts.filter((tokenId: bigint) => tokenId !== 0n); + }; +}); diff --git a/yarn-project/end-to-end/src/e2e_prover/e2e_prover_test.ts b/yarn-project/end-to-end/src/e2e_prover/e2e_prover_test.ts index 48d87598c82..fe3b7dac796 100644 --- a/yarn-project/end-to-end/src/e2e_prover/e2e_prover_test.ts +++ b/yarn-project/end-to-end/src/e2e_prover/e2e_prover_test.ts @@ -133,7 +133,7 @@ export class FullProverTest { this.accounts.map(a => a.address), ); - expect(await this.fakeProofsAsset.methods.admin().simulate()).toBe(this.accounts[0].address.toBigInt()); + expect(await this.fakeProofsAsset.methods.get_admin().simulate()).toBe(this.accounts[0].address.toBigInt()); }, ); } diff --git a/yarn-project/end-to-end/src/e2e_token_contract/access_control.test.ts b/yarn-project/end-to-end/src/e2e_token_contract/access_control.test.ts index 8efc7d88277..f916d848441 100644 --- a/yarn-project/end-to-end/src/e2e_token_contract/access_control.test.ts +++ b/yarn-project/end-to-end/src/e2e_token_contract/access_control.test.ts @@ -18,7 +18,7 @@ describe('e2e_token_contract access control', () => { it('Set admin', async () => { await t.asset.methods.set_admin(t.accounts[1].address).send().wait(); - expect(await t.asset.methods.admin().simulate()).toBe(t.accounts[1].address.toBigInt()); + expect(await t.asset.methods.get_admin().simulate()).toBe(t.accounts[1].address.toBigInt()); }); it('Add minter as admin', async () => { diff --git a/yarn-project/end-to-end/src/e2e_token_contract/token_contract_test.ts b/yarn-project/end-to-end/src/e2e_token_contract/token_contract_test.ts index ef34cc0403c..e6597c2745c 100644 --- a/yarn-project/end-to-end/src/e2e_token_contract/token_contract_test.ts +++ b/yarn-project/end-to-end/src/e2e_token_contract/token_contract_test.ts @@ -99,7 +99,7 @@ export class TokenContractTest { this.badAccount = await DocsExampleContract.at(badAccountAddress, this.wallets[0]); this.logger.verbose(`Bad account address: ${this.badAccount.address}`); - expect(await this.asset.methods.admin().simulate()).toBe(this.accounts[0].address.toBigInt()); + expect(await this.asset.methods.get_admin().simulate()).toBe(this.accounts[0].address.toBigInt()); }, ); diff --git a/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts b/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts index 64ce7d56196..3f14d4b34c5 100644 --- a/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts +++ b/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts @@ -114,7 +114,7 @@ export async function deployAndInitializeTokenAndBridgeContracts( // deploy l2 token bridge and attach to the portal const bridge = await TokenBridgeContract.deploy(wallet, token.address, tokenPortalAddress).send().deployed(); - if ((await token.methods.admin().simulate()) !== owner.toBigInt()) { + if ((await token.methods.get_admin().simulate()) !== owner.toBigInt()) { throw new Error(`Token admin is not ${owner}`); }