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.
constructor
- executed when the contract instance is deployedset_admin
- updates theadmin
of the contractset_minter
- adds a minter to theminters
mappingmint
- mints an NFT with a specifiedtoken_id
to the recipienttransfer_in_public
- publicly transfer the specified tokenfinalize_transfer_to_private
- finalize the transfer of the NFT from public to private context by completing the partial note(more on this below)
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 contractpublic_get_symbol
- returns the symbols of the NFT contractget_admin
- returns theadmin
account addressis_minter
- returns a boolean, indicating whether the provided address is a minterowner_of
- returns the owner of the providedtoken_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.
transfer_to_private
- privately initiates the transfer of an NFT from the public context to the private context by creating a partial noteprepare_private_balance_increase
- creates a partial note to transfer an NFT from the public context to the private context.cancel_authwit
- emits a nullifier to cancel a private authwittransfer_in_private
- transfers an NFT to another account, privatelytransfer_to_public
- transfers a NFT from private to public context
Private view
functions
These functions are useful for getting contract information in another contract in the private context.
private_get_symbol
- returns the NFT contract symbolprivate_get_name
- returns the NFT contract name
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.
_store_payload_in_transient_storage_unsafe
- a public function that is called when preparing a private balance increase. This function handles the needed public state updates.finalize_transfer_to_private_unsafe
- finalizes a transfer from public to private state
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 passedAztecAddress
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.
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).
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<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_id
s).
#[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.
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.
#[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
.
#[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.
#[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.
#[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
#[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.
#[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.
#[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.
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)
#[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.
#[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.
#[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
.
#[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.
#[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.
#[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.
#[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.
#[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.
#[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.
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
- Review the end to end tests (Github link) for reference.
- Nullifiers
- Public / Private function calls.
- Contract Storage
- Authwit