Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: NFT with "transient" storage shield flow #8129

Merged
merged 7 commits into from
Sep 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@
"unprefixed",
"unshield",
"unshielding",
"unshields",
"unzipit",
"updateable",
"upperfirst",
Expand Down
8 changes: 4 additions & 4 deletions noir-projects/aztec-nr/aztec/src/note/constants.nr
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 3 additions & 3 deletions noir-projects/aztec-nr/aztec/src/note/note_getter_options.nr
Original file line number Diff line number Diff line change
Expand Up @@ -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,
benesjan marked this conversation as resolved.
Show resolved Hide resolved
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 {
Expand Down
1 change: 1 addition & 0 deletions noir-projects/noir-contracts/Nargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions noir-projects/noir-contracts/contracts/nft_contract/Nargo.toml
Original file line number Diff line number Diff line change
@@ -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" }
291 changes: 291 additions & 0 deletions noir-projects/noir-contracts/contracts/nft_contract/src/main.nr
Original file line number Diff line number Diff line change
@@ -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<FieldCompressedString>,
// The name of the NFT
name: SharedImmutable<FieldCompressedString>,
benesjan marked this conversation as resolved.
Show resolved Hide resolved
// The admin of the contract
admin: PublicMutable<AztecAddress>,
// Addresses that can mint
minters: Map<AztecAddress, PublicMutable<bool>>,
// Contains the NFTs owned by each address in private.
private_nfts: Map<AztecAddress, PrivateSet<NFTNote>>,
// A map from token ID to a boolean indicating if the NFT exists.
nft_exists: Map<Field, PublicMutable<bool>>,
benesjan marked this conversation as resolved.
Show resolved Hide resolved
// A map from token ID to the public owner of the NFT.
public_owners: Map<Field, PublicMutable<AztecAddress>>,
benesjan marked this conversation as resolved.
Show resolved Hide resolved
}

#[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);
}
benesjan marked this conversation as resolved.
Show resolved Hide resolved

#[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
nventuro marked this conversation as resolved.
Show resolved Hide resolved
nventuro marked this conversation as resolved.
Show resolved Hide resolved
);

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);
benesjan marked this conversation as resolved.
Show resolved Hide resolved

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()
}
benesjan marked this conversation as resolved.
Show resolved Hide resolved

/// 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)
}
benesjan marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -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;
benesjan marked this conversation as resolved.
Show resolved Hide resolved
Loading
Loading