For the complete documentation index, see llms.txt.
Skip to main content
Version: Tierra (v6.0.0)

AIP-721: Non-Fungible Token

Source

AIP-721 defines a non-fungible token (NFT). Each token is identified by a unique token_id field. Tokens can be held privately in the note hash tree or publicly in a map from token_id to owner address.

Storage layout​

#[storage]
struct Storage<Context> {
symbol: PublicImmutable<FieldCompressedString, Context>,
name: PublicImmutable<FieldCompressedString, Context>,
private_nfts: Owned<PrivateSet<NFTNote, Context>, Context>,
nft_exists: Map<Field, PublicMutable<bool, Context>, Context>,
public_owners: Map<Field, PublicMutable<AztecAddress, Context>, Context>,
minter: PublicImmutable<AztecAddress, Context>,
}

nft_exists tracks whether a given token_id has been minted, while public_owners records the current public owner. When an NFT is moved to a private note, the public_owners entry is cleared and the NFT is stored as an NFTNote in the holder's private set.

NFTNote and partial-note support​

Each private NFT is represented as an NFTNote containing only the token_id:

/// A private note representing a token id associated to an account.
#[derive(Eq, Serialize, Packable)]
#[custom_note]
pub struct NFTNote {
/// The ID of the token represented by this note.
pub token_id: Field,
}

NFTNote::partial creates a PartialNFTNote, an incomplete note whose owner stays private:

pub fn partial(
owner: AztecAddress,
context: &mut PrivateContext,
recipient: AztecAddress,
completer: AztecAddress,
) -> PartialNFTNote {
// Safety: We use the randomness to preserve the privacy of the note recipient by preventing brute-forcing,
// so a malicious sender could use non-random values to make the note less private. But they already know
// the full note pre-image anyway, and so the recipient already trusts them to not disclose this
// information. We can therefore assume that the sender will cooperate in the random value generation.
let randomness = unsafe { random() };

// We create a commitment to the private data, which we then use to construct the log we send to the recipient.
let commitment = compute_partial_commitment(owner, randomness);

// Our partial note log encoding scheme includes a field with the tag of the public completion log, and we use
// the commitment as the tag. This is good for multiple reasons:
// - the commitment is uniquely tied to this partial note
// - the commitment is already public information, so we're not revealing anything else
// - we don't need to create any additional information, private or public, for the tag
// - other contracts cannot impersonate us and emit logs with the same tag due to public log siloing
let private_log_content = NFTPartialNotePrivateLogContent {};

do_private_message_delivery(
context,
|| {
encode_partial_note_private_message(
private_log_content,
owner,
randomness,
commitment,
)
},
Option::none(),
recipient,
MessageDelivery::onchain_unconstrained(),
);

let partial_note = PartialNFTNote { commitment };

// Now we compute the validity commitment and push it to the nullifier tree. It can be safely pushed to
// the nullifier tree since it uses its own separator, making collisions with actual note nullifiers
// practically impossible.
let validity_commitment = partial_note.compute_validity_commitment(completer);
context.push_nullifier_unsafe(validity_commitment);

partial_note
}

The commitment commits to the future owner and a random value, so completing the note cannot change who owns it. The storage slot and token_id become public when the note is completed. What needs protecting is who completes it: without some form of access control, any party could fill the commitment before the intended completer, for example with a different token. The validity commitment prevents this: it binds the partial note to the designated completer and is pushed as a nullifier, and completion checks that it exists for the completer passed in. The note does not check who the caller is, so the NFT contract passes msg_sender() as the completer, and only the designated completer can finalize the note. This mirrors the partial-note pattern in AIP-20 but applies it to NFT transfers.

Partial-note transfer commitment​

The external entry point for initiating a partial NFT transfer is:

#[external("private")]
fn initialize_transfer_commitment(to: AztecAddress, completer: AztecAddress) -> Field {
let commitment = self.internal._initialize_transfer_commitment(to, completer);
commitment.commitment
}

This function returns commitment.commitment, the Field stored in the PartialNFTNote. The AIP-20 equivalent returns commitment.to_field() for PartialUintNote.