Skip to main content

DIP721 NFT

View this sample's code on GitHub

This example demonstrates implementing an NFT canister. NFTs (non-fungible tokens) are unique tokens with arbitrary metadata, usually an image of some kind, to form the digital equivalent of trading cards. There are a few different NFT standards for the BigFile (e.g EXT, BIG-NFT), but this tutorial will use DIP-721. You can see a quick introduction on YouTube.

The canister is a basic implementation of the standard, with support for the minting, burning, and notification interface extensions.

The sample code is available in the samples repository in Rust and Motoko.

Command-line length limitations would prevent you from minting an NFT with a large file, like an image or video, via dfx. To that end, there is a command-line minting tool provided for minting simple NFTs.

Overview

The NFT canister is not very complicated since the DIP-721 standard specifies most CRUD operations, but we can still use it to explain three important concepts concerning dapp development for the BigFile:

1. Stable memory for canister upgrades.

The BigFile employs orthogonal persistence, so developers generally do not need to think a lot about storing their data. When upgrading canister code, however, it is necessary to explicitly handle canister data. The NFT canister example shows how stable memory can be handled using pre_upgrade and post_upgrade.

2. Certified data.

Generally, when a function only reads data, instead of modifying the state of the canister, it is beneficial to use a query call instead of an update call. But, since query calls do not go through consensus, certified responses should be used wherever possible. The HTTP interface of the Rust implementation shows how certified data can be handled.

3. Delegating control over assets.

For a multitude of reasons, users may want to give control over their assets to other identities, or even delete (burn) an item. The NFT canister example contains all those cases and shows how it can be done.

Architecture

Since the basic functions required in DIP-721 are very straightforward to implement, this section only discusses how the above ideas are handled and implemented.

Stable storage for canister upgrades

During canister code upgrades, memory does not persist between different canister calls. Only memory in stable memory is carried over. Because of that, it is necessary to write all data to stable memory before the upgrade happens, which is usually done in the pre_upgrade function. This function is called by the system before the upgrade happens. After the upgrade, it is normal to load data from stable memory into memory during the post_upgrade function. The post_upgrade function is called by the system after the upgrade happened. In case an error occurs during any part of the upgrade (including post_upgdrade), the entire upgrade is reverted.

The Rust CDK (Cube Development Kit) currently only supports one value in stable memory, so it is necessary to create an object that can hold everything you care about. In addition, not every data type can be stored in stable memory; only ones that implement the CandidType trait (usually via the CandidType derive macro) can be written to stable memory.

Since the state of our canister includes an RbTree which does not implement the CandidType, it has to be converted into a data structure (in this case a Vec) that implements CandidType. Luckily, both RbTree and Vec implement functions that allow converting to/from iterators, so the conversion can be done quite easily. After conversion, a separate StableState object is used to store data during the upgrade.

Certified data

To serve assets via HTTP over <canister-id>.icp0.io instead of <canister-id>.raw.icp0.io, responses have to contain a certificate to validate their content. Obtaining such a certificate can not happen during a query call since it has to go through consensus, so it has to be created during an update call.

A certificate is very limited in its content. At the time of writing, canisters can submit no more than 32 bytes of data to be certified. To make the most out of that small amount of data, a HashTree (the RbTree from the previous section is also a HashTree) is used. A HashTree is a tree-shaped data structure where the whole tree can be summarized (hashed) into one small hash of 32 bytes. Whenever some content of the tree changes, the hash also changes. If the hash of such a tree is certified, it means that the content of the tree can be considered certified. To see how data is certified in the NFT example canister, look at the function add_hash in http.rs.

For the response to be verified, it has to be checked that a) the served content is part of the tree, and b) the tree containing that content actually can be hashed to the certified hash. The function witness is responsible for creating a tree with minimal content that still can be verified to fulfill a) and b). Once this minimal tree is constructed, the certificate and minimal hash tree are sent as part of the BIG-Certificate header.

For a much more detailed explanation of how certification works, see this explanation video.

Managing control over assets

DIP-721 specifies multiple levels of control over the NFTs:

  • Owner: this person owns an NFT. They can transfer the NFT, add/remove operators, or burn the NFT.
  • Operator: sort of a delegated owner. The operator does not own the NFT but can do the same actions an owner can do.
  • Custodian: creator of the NFT collection/canister. They can do anything (transfer, add/remove operators, burn, and even un-burn) to NFTs, but also mint new ones or change the symbol or description of the collection.

The NFT example canister keeps access control in these three levels very simple:

  • For every level of control, a separate list (or set) of principals is kept.
  • Those three levels are then manually checked every single time someone attempts to do something for which they require authorization.
  • If a user is not authorized to call a certain function an error is returned.

Burning an NFT is a special case. To burn an NFT means to either delete the NFT (not intended in DIP-721) or to set ownership to null (or a similar value). On the BigFile, this non-existing principal is called the management canister.

"The BIG management canister is just a facade; it does not exist as a canister (with isolated state, Wasm code, etc.)," and its address is aaaaa-aa. Using this management canister address, we can construct its principal and set the management canister as the owner of a burned NFT.

NFT sample code tutorial

A running instance of the Rust canister for demonstration purposes is available as t5l7c-7yaaa-aaaab-qaehq-cai. The interface is meant to be programmatic, but the Rust version additionally contains HTTP functionality so you can view a metadata file at <canister URL>/<NFT ID>/<file ID>. It contains six NFTs, so you can look at items from <canister URL>/0/0 to <canister URL>/5/0.

Prerequisites

  • Install the BIG SDK.

  • Download and install git.

  • wasm32-unknown-unknown targets; these can be installed with rustup target add wasm32-unknown-unknown.

    Step 1: Clone the Github repo for the project's files and navigate into the directory:

git clone https://github.com/dfinity/examples
cd examples/rust/dip721-nft-container

Step 2: Start the local replica before installing the canister:

dfx start --background --clean

Step 3: Install the canister.

Deploy the canister with the command:

dfx deploy --no-wallet --argument \
"(record {
name = \"Numbers One Through Fifty\";
symbol = \"NOTF\";
logo = opt record {
data = \"$(base64 -i ./logo.png)\";
logo_type = \"image/png\";
};
custodians = opt vec { principal \"$(dfx identity get-principal)\" };
})"

The canister expects a record parameter with the following fields:

  • custodians: A list of users allowed to manage the canister. If unset, it will default to the caller. If you're using dfx, and haven't specified --no-wallet, that's your wallet principal, not your own, so be careful!

  • name: The name of your NFT collection. Required.

  • symbol: A short slug identifying your NFT collection. Required.

  • logo: The logo of your NFT collection, represented as a record with fields data (the base-64 encoded logo) and logo_type (the MIME type of the logo file). If unset, it will default to the BigFile logo.

    Step 4: Interact with the canister.

Aside from the standard functions, it has five extra functions:

  • set_name, set_symbol, set_logo, and set_custodian: these functions update the collection information of the corresponding field from when it was initialized.
  • is_custodian: this function checks whether the specified user is a custodian.

The canister also supports a certified HTTP interface; going to /<nft>/<id> will return nft's metadata file #id, with /<nft> returning the first non-preview file.

Remember that query functions are uncertified; the result of functions like ownerOfDip721 can be modified arbitrarily by a single malicious node. If queried information is depended on, for example, if someone might send BIG to the owner of a particular NFT to buy it from them, those calls should be performed as update calls instead. You can force an update call by passing the --update flag to dfx or using the Agent::update function in agent-rs.

Step 5: Mint an NFT.

Due to size limitations on the length of a terminal command, an image- or video-based NFT would be impossible to send via dfx. To that end, there is an experimental minting tool you can use to mint a single-file NFT.

To use this tool, install the minting tool with the command:

cargo install --git https://github.com/dfinity/experimental-minting-tool --locked

As an example, to mint the default logo, you would run the following command:

minting-tool local "$(dfx canister id dip721_nft_container)" --owner "$(dfx identity get-principal)" --file ./logo.png --sha2-auto

The output of this command should look like this:

Successfully minted token 0 to x4d3z-ufpaj-lpxs4-v7gmt-v56ze-aub3k-bvifl-y4lsq-soafd-d3i4k-fqe (transaction id 0)

Minting is restricted to anyone authorized with the custodians parameter or the set_custodians function. Since the contents of --file are stored on-chain, it's important to prevent arbitrary users from minting tokens, or they will be able to store arbitrarily-sized data in the contract and exhaust the canister's cycles. Be careful not to upload too much data to the canister yourself, or the contract will no longer be able to be upgraded afterward.

Demo

This Rust example comes with a demo script, demo.sh, which runs through an example workflow with minting and trading an NFT between a few users. This is primarily designed to be read rather than run so that you can use it to see how basic NFT operations are done. For a more in-depth explanation, read the [standard][DIP721].

Security considerations and best practices

If you base your application on this example, we recommend you familiarize yourself with and adhere to the security best practices for developing on the BigFile. This example may not implement all the best practices.

For example, the following aspects are particularly relevant for this app:

Resources