Skip to main content

NFT contract

In this tutorial we will go through writing an L2 native NFT token contract for the Aztec Network, using the Aztec.nr contract libraries.

This tutorial is intended to help you get familiar with the Aztec.nr library, Aztec contract syntax and some of the underlying structure of the Aztec network.

In this tutorial you will learn how to:

  • Write public functions that update public state
  • Write private functions that update private state
  • Implement access control on public and private functions
  • Handle math operations safely
  • Handle different private note types
  • Pass data between private and public state

We are going to start with a blank project and fill in the token contract source code defined here (GitHub Link), and explain what is being added as we go.

Requirements

You will need to have aztec-nargo installed in order to compile Aztec.nr contracts.

Project setup

Create a new project with:

aztec-nargo new --contract nft_contract

Your file structure should look something like this:

.
|--nft_contract
| |--src
| | |--main.nr
| |--Nargo.toml

Inside Nargo.toml paste the following:

[dependencies]
aztec = { git="https://github.com/AztecProtocol/aztec-packages/", tag="aztec-packages-v0.68.2", directory="noir-projects/aztec-nr/aztec" }
authwit={ git="https://github.com/AztecProtocol/aztec-packages/", tag="aztec-packages-v0.68.2", directory="noir-projects/aztec-nr/authwit"}
compressed_string = {git="https://github.com/AztecProtocol/aztec-packages/", tag="aztec-packages-v0.68.2", directory="noir-projects/aztec-nr/compressed-string"}

We will be working within main.nr for the rest of the tutorial.

Execution contexts

Before we go further, a quick note about execution contexts.

Transactions are initiated in the private context (executed client-side), then move to the L2 public context (executed remotely by an Aztec sequencer), then to the Ethereum L1 context (executed by an Ethereum node).

Step 1. Private Execution

Users provide inputs and execute locally on their device for privacy reasons. Outputs of the private execution are commitment and nullifier updates, a proof of correct execution and any return data to pass to the public execution context.

Step 2. Public Execution

This happens remotely by the sequencer, which takes inputs from the private execution and runs the public code in the network virtual machine, similar to any other public blockchain.

Step 3. Ethereum execution

Aztec transactions can pass messages to Ethereum contracts through the rollup via the outbox. The data can be consumed by Ethereum contracts at a later time, but this is not part of the transaction flow for an Aztec transaction. The technical details of this are beyond the scope of this tutorial, but we will cover them in an upcoming piece.

How this will work

Before writing the functions, let's go through them to see how this contract will work:

Initializer

There is one initializer function in this contract, and it will be selected and executed once when the contract is deployed, similar to a constructor in Solidity. This is marked public, so the function logic will be transparent.

Public functions

These are functions that have transparent logic, will execute in a publicly verifiable context and can update public storage.

Public view functions

These functions are useful for getting contract information for use in other contracts, in the public context.

  • public_get_name - returns name of the NFT contract
  • public_get_symbol - returns the symbols of the NFT contract
  • get_admin - returns the admin account address
  • is_minter - returns a boolean, indicating whether the provided address is a minter
  • owner_of - returns the owner of the provided token_id

Private functions

These are functions that have private logic and will be executed on user devices to maintain privacy. The only data that is submitted to the network is a proof of correct execution, new data commitments and nullifiers, so users will not reveal which contract they are interacting with or which function they are executing. The only information that will be revealed publicly is that someone executed a private transaction on Aztec.

Private view functions

These functions are useful for getting contract information in another contract in the private context.

Internal functions

Internal functions are functions that can only be called by the contract itself. These can be used when the contract needs to call one of it's public functions from one of it's private functions.

Unconstrained functions

Unconstrained functions can be thought of as view functions from Solidity--they only return information from the contract storage or compute and return data without modifying contract storage. They are distinguished from functions with the #[view] annotation in that unconstrained functions cannot be called by other contracts.

  • get_private_nfts - Returns an array of token IDs owned by the passed AztecAddress in private and a flag indicating whether a page limit was reached.

Contract dependencies

Before we can implement the functions, we need set up the contract storage, and before we do that we need to import the appropriate dependencies.

Copy required files

We will be going over the code in main.nr here (GitHub link). If you are following along and want to compile main.nr yourself, you need to add the other files in the directory as they contain imports that are used in main.nr.

Paste these imports:

mod types;
mod test;

use dep::aztec::macros::aztec;

// Minimal NFT implementation with `AuthWit` support that allows minting in public-only and transfers in both public
// and private.
#[aztec]
contract NFT {
use crate::types::nft_note::NFTNote;
use dep::authwit::auth::{
assert_current_call_valid_authwit, assert_current_call_valid_authwit_public,
compute_authwit_nullifier,
};
use dep::aztec::{
encrypted_logs::encrypted_note_emission::encode_and_encrypt_note,
macros::{
events::event,
functions::{initializer, internal, private, public, view},
storage::storage,
},
note::constants::MAX_NOTES_PER_PAGE,
oracle::random::random,
prelude::{
AztecAddress, Map, NoteGetterOptions, NoteViewerOptions, PrivateContext, PrivateSet,
PublicContext, PublicImmutable, PublicMutable,
},
protocol_types::{point::Point, traits::Serialize},
utils::comparison::Comparator,
};
use dep::compressed_string::FieldCompressedString;
use std::meta::derive;
}

We are importing:

  • CompressedString to hold the token symbol
  • Types from aztec::prelude
  • Types for storing note types

Types files

We are also importing types from a types.nr file, which imports types from the types folder. You can view them here (GitHub link).

note

Private state in Aztec is all UTXOs.

Contract Storage

Now that we have dependencies imported into our contract we can define the storage for the contract.

Below the dependencies, paste the following Storage struct:

storage_struct
#[storage]
struct Storage<Context> {
// The symbol of the NFT
symbol: PublicImmutable<FieldCompressedString, Context>,
// The name of the NFT
name: PublicImmutable<FieldCompressedString, Context>,
// The admin of the contract
admin: PublicMutable<AztecAddress, Context>,
// Addresses that can mint
minters: Map<AztecAddress, PublicMutable<bool, Context>, Context>,
// Contains the NFTs owned by each address in private.
private_nfts: Map<AztecAddress, PrivateSet<NFTNote, Context>, Context>,
// A map from token ID to a boolean indicating if the NFT exists.
nft_exists: Map<Field, PublicMutable<bool, Context>, Context>,
// A map from token ID to the public owner of the NFT.
public_owners: Map<Field, PublicMutable<AztecAddress, Context>, Context>,
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L46-L64

Custom Notes

The contract storage uses a custom note implementation. Custom notes are useful for defining your own data types. You can think of a custom note as a "chunk" of private data, the entire thing is added, updated or nullified (deleted) together. This NFT note is very simple and stores only the owner and the token_id and uses randomness to hide its contents.

Randomness is required because notes are stored as commitments (hashes) in the note hash tree. Without randomness, the contents of a note may be derived through brute force (e.g. without randomness, if you know my Aztec address, you may be able to figure out which note hash in the tree is mine by hashing my address with many potential token_ids).

nft_note
#[partial_note(quote { token_id})]
pub struct NFTNote {
// ID of the token
token_id: Field,
// The owner of the note
owner: AztecAddress,
// Randomness of the note to hide its contents
randomness: Field,
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/types/nft_note.nr#L13-L23

The custom note implementation also includes the nullifier computation function. This tells the protocol how the note should be nullified.

compute_nullifier
fn compute_nullifier(
self,
context: &mut PrivateContext,
note_hash_for_nullify: Field,
) -> Field {
let owner_npk_m_hash: Field = get_public_keys(self.owner).npk_m.hash();
let secret = context.request_nsk_app(owner_npk_m_hash);
poseidon2_hash_with_separator(
[note_hash_for_nullify, secret],
GENERATOR_INDEX__NOTE_NULLIFIER as Field,
)
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/types/nft_note.nr#L26-L39

Functions

Copy and paste the body of each function into the appropriate place in your project if you are following along.

Constructor

This function sets the admin and makes them a minter, and sets the name and symbol.

constructor
#[public]
#[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));
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L66-L76

Public function implementations

Public functions are declared with the #[public] macro above the function name.

As described in the execution contexts section above, public function logic and transaction information is transparent to the world. Public functions update public state, but can be used to finalize notes prepared in a private context (partial notes flow).

Storage is referenced as storage.variable.

set_admin

The function checks that the msg_sender is the admin. If not, the transaction will fail. If it is, the new_admin is saved as the admin.

set_admin
#[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);
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L78-L84

set_minter

This function allows the admin to add or a remove a minter from the public minters mapping. It checks that msg_sender is the admin and finally adds the minter to the minters mapping.

set_minter
#[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);
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L86-L92

mint

This public function checks that the token_id is not 0 and does not already exist and the msg_sender is authorized to mint. Then it indicates that the token_id exists, which is useful for verifying its existence if it gets transferred to private, and updates the owner in the public_owners mapping.

mint
#[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);
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L94-L105

transfer_in_public

transfer_in_public
#[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);
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L147-L161
Authorizing token spends (via authwits)

If the msg_sender is NOT the same as the account to debit from, the function checks that the account has authorized the msg_sender contract to debit tokens on its behalf. This check is done by computing the function selector that needs to be authorized, computing the hash of the message that the account contract has approved. This is a hash of the contract that is approved to spend (context.msg_sender), the token contract that can be spent from (context.this_address()), the selector, the account to spend from (from), the amount and a nonce to prevent multiple spends. This hash is passed to assert_inner_hash_valid_authwit_public to ensure that the Account Contract has approved tokens to be spent on it's behalf.

If the msg_sender is the same as the account to debit from, the authorization check is bypassed and the function proceeds to update the public owner.

finalize_transfer_to_private

This public function finalizes a transfer that has been set up by a call to prepare_private_balance_increase by reducing the public balance of the associated account and emitting the note for the intended recipient.

finalize_transfer_to_private
#[public]
fn finalize_transfer_to_private(token_id: Field, hiding_point_slot: Field) {
let from = context.msg_sender();
_finalize_transfer_to_private(from, token_id, hiding_point_slot, &mut context, storage);
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L259-L265

Private function implementations

Private functions are declared with the #[private] macro above the function name like so:

    #[private]
fn transfer_in_private(

As described in the execution contexts section above, private function logic and transaction information is hidden from the world and is executed on user devices. Private functions update private state, but can pass data to the public execution context (e.g. see the transfer_to_public function).

Storage is referenced as storage.variable.

transfer_to_private

Transfers token with token_id from public balance of the sender to a private balance of to. Calls _prepare_private_balance_increase to get the hiding point slot (a transient storage slot where we can keep the partial note) and then calls _finalize_transfer_to_private_unsafe to finalize the transfer in the public context.

transfer_to_private
#[private]
fn transfer_to_private(to: AztecAddress, token_id: Field) {
let from = context.msg_sender();

let nft = NFT::at(context.this_address());

// We prepare the private balance increase.
let hiding_point_slot = _prepare_private_balance_increase(to, &mut context, storage);

// At last we finalize the transfer. Usage of the `unsafe` method here is safe because we set the `from`
// function argument to a message sender, guaranteeing that he can transfer only his own NFTs.
nft._finalize_transfer_to_private_unsafe(from, token_id, hiding_point_slot).enqueue(
&mut context,
);
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L164-L180

prepare_private_balance_increase

This function prepares a partial note to transfer an NFT from the public context to the private context. The caller specifies an AztecAddress that will receive the NFT in private storage.

note

This function calls _prepare_private_balance_increase which is marked as #[contract_library_method], which means the compiler will inline the _prepare_private_balance_increase function. Click through to the source to see the implementation.

It also calls _store_payload_in_transient_storage_unsafe to store the partial note in "transient storage" (more below)

prepare_private_balance_increase
#[private]
fn prepare_private_balance_increase(to: AztecAddress) -> Field {
_prepare_private_balance_increase(to, &mut context, storage)
}

/// This function exists separately from `prepare_private_balance_increase` solely as an optimization as it allows
/// us to have it inlined in the `transfer_to_private` function which results in one less kernel iteration.
///
/// TODO(#9180): Consider adding macro support for functions callable both as an entrypoint and as an internal
/// function.
#[contract_library_method]
fn _prepare_private_balance_increase(
to: AztecAddress,
context: &mut PrivateContext,
storage: Storage<&mut PrivateContext>,
) -> Field {
let to_note_slot = storage.private_nfts.at(to).storage_slot;

// We create a setup payload with unpopulated/zero token id for 'to'
// TODO(#7775): Manually fetching the randomness here is not great. If we decide to include randomness in all
// notes we could just inject it in macros.
let note_randomness = unsafe { random() };
let note_setup_payload = NFTNote::setup_payload().new(to, note_randomness, to_note_slot);

let setup_log = note_setup_payload.encrypt_log(context, to, context.msg_sender());

// Using the x-coordinate as a hiding point slot is safe against someone else interfering with it because
// we have a guarantee that the public functions of the transaction are executed right after the private ones
// and for this reason the protocol guarantees that nobody can front-run us in consuming the hiding point.
// This guarantee would break if `finalize_transfer_to_private` was not called in the same transaction. This
// however is not the flow we are currently concerned with. To support the multi-transaction flow we could
// introduce a `from` function argument, hash the x-coordinate with it and then repeat the hashing in
// `finalize_transfer_to_private`.

// We can also be sure that the `hiding_point_slot` will not overwrite any other value in the storage because
// in our state variables we derive slots using a different hash function from multi scalar multiplication
// (MSM).
let hiding_point_slot = note_setup_payload.hiding_point.x;

// We don't need to perform a check that the value overwritten by `_store_point_in_transient_storage_unsafe`
// is zero because the slot is the x-coordinate of the hiding point and hence we could only overwrite
// the value in the slot with the same value. This makes usage of the `unsafe` method safe.
NFT::at(context.this_address())
._store_payload_in_transient_storage_unsafe(
hiding_point_slot,
note_setup_payload.hiding_point,
setup_log,
)
.enqueue(context);

hiding_point_slot
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L184-L237

cancel_authwit

Cancels a private authwit by emitting the corresponding nullifier.

cancel_authwit
#[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);
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L305-L312

transfer_in_private

Transfers an NFT between two addresses in the private context. Uses authwits to allow contracts to transfer NFTs on behalf of other accounts.

transfer_in_private
#[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 mut new_note = NFTNote::new(token_id, to);
nfts.at(to).insert(&mut new_note).emit(encode_and_encrypt_note(&mut context, to, from));
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L314-L333

transfer_to_public

Transfers and NFT from private storage to public storage. The private call enqueues a call to _finish_transfer_to_public which updates the public owner of the token_id.

transfer_to_public
#[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,
);
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L335-L353

Internal function implementations

Internal functions are functions that can only be called by this contract. The following 3 functions are public functions that are called from the private execution context. Marking these as internal ensures that only the desired private functions in this contract are able to call them. Private functions defer execution to public functions because private functions cannot update public state directly.

_store_payload_in_transient_storage_unsafe

It is labeled unsafe because the public function does not check the value of the storage slot before writing, but it is safe because of the private execution preceding this call.

This is transient storage since the storage is not permanent, but is scoped to the current transaction only, after which it will be reset. The partial note is stored the "hiding point slot" value (computed in _prepare_private_balance_increase()) in public storage. However subseqeuent enqueued call to _finalize_transfer_to_private_unsafe() will read the partial note in this slot, complete it and emit it. Since the note is completed, there is no use of storing the hiding point slot anymore so we will reset to empty. This saves a write to public storage too.

store_payload_in_transient_storage_unsafe
#[public]
#[internal]
fn _store_payload_in_transient_storage_unsafe(
slot: Field,
point: Point,
setup_log: [Field; 9],
) {
context.storage_write(slot, point);
context.storage_write(slot + aztec::protocol_types::point::POINT_LENGTH as Field, setup_log);
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L244-L255

_finalize_transfer_to_private_unsafe

This function is labeled as unsafe because the sender is not enforced in this function, but it is safe because the sender is enforced in the execution of the private function that calls this function.

finalize_transfer_to_private_unsafe
#[public]
#[internal]
fn _finalize_transfer_to_private_unsafe(
from: AztecAddress,
token_id: Field,
hiding_point_slot: Field,
) {
_finalize_transfer_to_private(from, token_id, hiding_point_slot, &mut context, storage);
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L267-L277

_finish_transfer_to_public

Updates the public owner of the token_id to the to address.

finish_transfer_to_public
#[public]
#[internal]
fn _finish_transfer_to_public(to: AztecAddress, token_id: Field) {
storage.public_owners.at(token_id).write(to);
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L355-L361

View function implementations

View functions in Aztec are similar to view functions in Solidity in that they only return information from the contract storage or compute and return data without modifying contract storage. These functions are different from unconstrained functions in that the return values are constrained by their definition in the contract.

Public view calls that are part of a transaction will be executed by the sequencer when the transaction is being executed, so they are not private and will reveal information about the transaction. Private view calls can be safely used in private transactions for getting the same information.

get_admin

A getter function for reading the public admin value.

admin
#[public]
#[view]
fn get_admin() -> Field {
storage.admin.read().to_field()
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L131-L137

is_minter

A getter function for checking the value of associated with a minter in the public minters mapping.

is_minter
#[public]
#[view]
fn is_minter(minter: AztecAddress) -> bool {
storage.minters.at(minter).read()
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L139-L145

owner_of

Returns the owner of the provided token_id. Reverts if the token_id does not exist. Returns the zero address if the token_id does not have a public owner.

public_get_name

Returns the name of the NFT contract in the public context.

public_get_symbol

Returns the symbol of the NFT contract in the public context.

private_get_name

Returns the name of the NFT contract in the private context.

private_get_symbol

Returns the symbol of the NFT contract in the private context.

Unconstrained function implementations

Unconstrained functions are similar to view functions in Solidity in that they only return information from the contract storage or compute and return data without modifying contract storage. They are different from view functions in that the values are returned from the user's PXE and are not constrained by the contract's definition--if there is bad data in the user's PXE, they will get bad data back.

get_private_nfts

A getter function for checking the private balance of the provided Aztec account. Returns an array of token IDs owned by owner in private and a flag indicating whether a page limit was reached.

get_private_nfts
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)
}
Source code: noir-projects/noir-contracts/contracts/nft_contract/src/main.nr#L374-L393

Compiling

Now that the contract is complete, you can compile it with aztec-nargo. See the Sandbox reference page for instructions on setting it up.

Run the following command in the directory where your Nargo.toml file is located:

aztec-nargo compile

Once your contract is compiled, optionally generate a typescript interface with the following command:

aztec codegen target -o src/artifacts

Optional: Dive deeper into this contract and concepts mentioned here