3. Creating NFTs on ICP
A non-fungible token, commonly referred to as an NFT, is a type of tokenized asset that is assigned a unique identifier that is used to distinguish one NFT for another. An NFT cannot be replicated or reproduced since they are cryptographically unique. A non-fungible token is a type of token that cannot be exchanged 1:1 with another token of the same type, as the value of the NFT token can vary. In comparison, a fungible token can always be transferred 1:1 for another token of the same type.
For example, 1 USD can always be exchanged for 1 USD. However, 1 unique painting cannot be exchanged for another unique painting, since the value of the two paintings will be different.
On the Internet Computer, ICP is a fungible token that can always be exchanged for ICP of equal value. However, an NFT token deployed on ICP cannot be traded 1:1 with another NFT token deployed on ICP.
NFTs have enabled a wide range of different use-cases, since the ownership of an NFT can be verified via the blockchain and cannot be spoofed or faked. Just a few use-cases for NFTs include:
-
Providing a way to buy, sell, or trade unique artwork or collectibles.
-
Providing a way to buy digital assets, such as domain names or virtual worlds.
-
Providing exclusive membership to gated content for users who purchase an NFT from a specific collection.
-
Providing proof of ownership for real-world tangible assets, such as property.
-
Providing proof of documents, such as an identification card or driver's license.
-
Providing a way for content creators to monetize the content they create by minting it as an NFT.
How do NFTs work?
Similar to fungible tokens, NFTs are created through a process known as minting. Minting is when a token is created on the blockchain by recording the token's data to the chain. When an NFT is minted on ICP, a canister smart contract is used to define the NFT's ownership, data and metadata, and provide the ability for the NFT to be transferred or sold to another user.
When an NFT is created, it is assigned a unique identifier that is used to distinguish the token from all other NFTs. Each token's data is public, including the ownership information, metadata information, and transaction history.
Some NFTs are '1 of 1' tokens, meaning they are globally unique and not part of a series or collection of NFTs. Other times, NFT tokens are '1 of 100' or '1 of 500', meaning they are part of a larger series that several people can purchase and retain the same benefits. '1 of 1' NFTs are often used in cases of unique art or identifying documents, while '1 of 100' NFTs are often used for providing membership to exclusive content or providing tickets to an event.
Even if 5_000 NFTs of the same exact item are minted, each token will have a unique identifier to define it among the others in the series. In some cases, each NFT in the collection will have a unique image to identify it visually; other times, each NFT will be exactly the same except for the unique token ID.
NFT standards
Similar to token standards, such as the ICRC-1 and ICRC-2 fungible token standards, NFTs are required to use a standard that sets the guidelines for API methods that support necessary NFT functionalities. Some common API methods for NFTs are the ability to mint an NFT, transfer the NFT, and query the NFT's metadata.
Currently on ICP, there are two NFT standards: DIP721 and ICRC-7.
DIP721
The DIP721 NFT standard is designed as an Internet Computer adaptation of the ERC-721 non-fungible token standard. It is designed to provide a simple, non-ambiguous API for transferring and tracking the ownership of NFTs deployed on ICP.
DIP721 was designed to improve the existing ICP token standards by implementing the following:
-
Providing proper metadata support for an NFT.
-
Incurring a lesser cycles cost than multi-token standards.
-
Providing the ability to track the history of NFT transfers.
-
Providing an API that closely follows the original EIP-721 standard, making the ability to import existing Ethereum contracts onto ICP more straightforward.
ICRC-7
ICRC-7 is a new standard for non-fungible tokens on ICP. Recall that "ICRC" stands for "Internet Computer Request for Comments", and is the standard created by the Internet Computer working group. An ICRC standard can be used for creating anything on ICP, not just fungible tokens such as the ICRC-1 and ICRC-2 token standards.
The ICRC-7 standard is designed to be a minimal standard for allowing an NFT collection to be deployed on ICP. In an NFT collection, each NFT may have unique metadata information. This metadata may include a unique image, traits or tags, or a description describing the NFT.
The ICRC-7 standard implements several additional API methods compared to the DIP721 standard, such as batch query methods, batch update methods, and collection approval methods.
Creating and deploying an NFT
In this tutorial, you'll create an NFT collection that will allow for multiples of the same NFT to be minted, but each will have the same benefits and attributes.
Prerequisites
Before you start, verify that you have set up your developer environment according to the instructions in 3. Developer environment setup.
Cloning the azle-dip721-nft-standard
example
To get started, open a new terminal window, navigate into your working directory (developer_journey
), then use the following commands to clone the example repo and navigate into the azle-dip721-nft-standard
directory:
git clone https://github.com/Jonath-z/azle-dip721-nft-standard
cd examples/motoko/azle-dip721-nft-standard
Confirm your local identity
Verify that you are using the local identity that you'd like to be for deploying and minting your NFTs. This example will showcase using the DevJourney
identity:
dfx identity use DevJourney
Reviewing the project's files
├── README.md
├── dfx.json
└── src
└── backend
├── index.ts
└── index.did
.
.
.
This project uses a single-canister architecture as there is only one backend canister defined in the dfx.json
file. This canister, called backend
, uses the source code found in the file src/backend/index.ts
:
{
"canisters": {
"backend": {
"type": "custom",
"main": "src/backend/index.ts",
"candid": "src/backend/index.did",
"build": "npx azle backend",
"wasm": ".azle/backend/backend.wasm",
"gzip": true,
"assets": [["src/frontend/dist", "dist"]],
"build_assets": "npm run build",
"metadata": [
{
"name": "candid:service",
"path": "src/backend/index.did"
},
{
"name": "cdk:name",
"content": "azle"
}
]
}
}
}
This project provides a frontend which is not going to be used in this tutorial, meaning the project can only be interacted with by making calls to the backend canister via the CLI or through the Candid UI interface in a local web browser.
Next, open and review the canister's source code file at src/backend/index.ts
. The code has been annotated with comments to explain the code's logic:
// Importing required types and functions from Azle
import {
blob,
Canister,
ic,
nat64,
nat,
Principal,
query,
update,
Variant,
Vec,
init,
text,
nat8,
nat32,
Record,
Opt,
Result,
Ok,
Err,
} from "azle";
// Defining the MetadataVal type as a variant with different possible content types
const MetadataVal = Variant({
TextContent: text,
BlobContent: blob,
NatContent: nat,
Nat8Content: nat8,
Nat16Content: nat8,
Nat32Content: nat32,
Nat64Content: nat64,
});
// Defining the LogoResult type as a record with logo_type and data fields
const LogoResult = Record({
logo_type: text,
data: text,
});
// Defining the MetadataKeyVal type as a record with key and val fields
const MetadataKeyVal = Record({
key: text,
val: MetadataVal,
});
// Defining the MintResult type as a record with tokenId and id fields
const MintResult = Record({
tokenId: nat64,
id: nat,
});
// Defining the MetadataPart type as a record with key_val_data and data fields
const MetadataPart = Record({
key_val_data: Vec(MetadataKeyVal),
data: blob,
});
// Defining the MetadataDesc type as a vector of MetadataPart
const MetadataDesc = Vec(MetadataPart);
// Defining the Error type as a variant with different error messages
const Error = Variant({
Unauthorized: text,
InvalidTokenId: text,
ZeroAddress: text,
Other: text,
});
// Defining the InitArgs type as a record with logo, name, and symbol fields
const InitArgs = Record({
logo: LogoResult,
name: text,
symbol: text,
});
// Defining the Nft type as a record with owner, approved, id, and metadata fields
const Nft = Record({
owner: Principal,
approved: Opt(Principal),
id: nat64,
metadata: MetadataDesc,
});
// Defining the State type as a record with nfts, custodians, logo, name, symbol, and txid fields
const State = Record({
nfts: Vec(Nft),
custodians: Vec(Principal),
logo: LogoResult,
name: text,
symbol: text,
txid: nat,
});
// Initializing the state with default values
let state: State = {
nfts: [],
custodians: [],
name: "",
symbol: "",
txid: 0n,
};
// Exporting the canister with its methods
export default Canister({
// Initialization method for the canister
init: init([Principal, InitArgs], (custodian, args) => {
// Setting the initial custodians
state.custodians = [custodian];
// Setting the name of the NFT
state.name = args.name;
// Setting the symbol of the NFT
state.symbol = args.symbol;
// Setting the logo of the NFT
state.logo = args.logo;
}),
// Query method to get the balance of a user
balanceOf: query([Principal], nat64, (user) => {
// Calculating the balance by filtering NFTs owned by the user
return BigInt(
state.nfts.filter(
(n: { owner: { toText: () => string } }) =>
n.owner.toText() === user.toText()
).length
);
}),
// Query method to get the owner of a specific tokenId
ownerOf: query([nat64], Result(Principal, Error), (tokenId) => {
// Retrieving the NFT by tokenId
const nft = state.nfts[Number(tokenId)];
// Returning the owner if the NFT exists, otherwise an error
if (nft) {
return Ok(nft.owner);
} else {
return Err({ InvalidTokenId: "true" });
}
}),
// Query method to get the logo of the canister
logo: query([], Result(LogoResult, Error), () => {
// Returning the logo if it exists, otherwise an error
return state.logo ? Ok(state.logo) : Err({ Other: "true" });
}),
// Query method to get the name of the canister
name: query([], text, () => {
// Returning the name of the canister
return state.name;
}),
// Query method to get the symbol of the canister
symbol: query([], text, () => {
// Returning the symbol of the canister
return state.symbol;
}),
// Query method to get the total supply of NFTs
totalSupply: query([], nat64, () => {
// Returning the total number of NFTs
return BigInt(state.nfts.length);
}),
// Query method to get the metadata of a specific tokenId
getMetadata: query([nat64], Result(MetadataDesc, Error), (tokenId) => {
// Retrieving the NFT by tokenId
const nft = state.nfts[Number(tokenId)];
// Returning the metadata if the NFT exists, otherwise an error
if (nft) {
return Ok(nft.metadata);
} else {
return Err({ InvalidTokenId: "true" });
}
}),
// Query method to get the metadata of all NFTs owned by a user
getMetadataForUser: query([Principal], Vec(MetadataDesc), (user) => {
// Filtering NFTs owned by the user and returning their metadata
return state.nfts
.filter(
(n: { owner: { toText: () => string } }) =>
n.owner.toText() === user.toText()
)
.map((n: { metadata: any }) => n.metadata);
}),
// Update method to mint a new NFT
mint: update(
[Principal, MetadataDesc],
Result(MintResult, Error),
(to, metadata) => {
// Logging the metadata
console.log({ metadata });
// Creating a new ID for the NFT
const newId = BigInt(state.nfts.length);
// Creating the new NFT object
const nft = {
owner: to,
approved: undefined,
id: newId,
metadata,
};
// Adding the new NFT to the state
state.nfts.push(nft);
// Returning the mint result with the new ID and token ID
return Ok({ id: nextTxId(), tokenId: newId });
}
),
// Update method to safely transfer an NFT from one user to another
safeTransferFrom: update(
[Principal, Principal, nat],
Result(nat, Error),
(from: Principal, to: Principal, tokenId: nat) => {
// Checking if the destination address is valid
if (to.toText() === Principal.anonymous().toText()) {
// Returning an error if the address is invalid
return Err({ ZeroAddress: "true" });
} else {
// Transferring the NFT if the address is valid
return transferFrom(from, to, tokenId);
}
}
),
// Update method to burn an NFT
burn: update([nat64], Result(nat, Error), (tokenId) => {
// Retrieving the NFT by tokenId
const nft = state.nfts[Number(tokenId)];
// Checking if the caller is the owner of the NFT
if (nft.owner.toText() !== ic.caller().toText()) {
// Returning an error if the caller is not the owner
return Err({ Unauthorized: "true" });
}
// Setting the owner of the NFT to anonymous (burning it)
nft.owner = Principal.anonymous();
// Returning the transaction ID of the burn operation
return Ok(nextTxId());
}),
});
// Helper function to transfer an NFT from one user to another
function transferFrom(from: Principal, to: Principal, tokenId: nat) {
// Retrieving the NFT by tokenId
const nft = state.nfts[Number(tokenId)];
// Checking if the NFT exists
if (!nft) {
// Returning an error if the NFT does not exist
return Err({ InvalidTokenId: "true" });
}
// Checking if the caller is authorized to transfer the NFT
if (
nft.owner.toText() !== from.toText() &&
nft.approved?.toText() !== from.toText()
) {
// Returning an error if the caller is not authorized
return Err({ Unauthorized: "true" });
}
// Checking if the from address is the owner of the NFT
if (nft.owner.toText() !== from.toText()) {
// Returning an error if the from address is not the owner
return Err({ Other: "true" });
}
// Setting the approved address to the destination address
nft.approved = to;
// Setting the owner of the NFT to the destination address
nft.owner = to;
// Returning the transaction ID of the transfer operation
return Ok(nextTxId());
}
// Helper function to get the next transaction ID
function nextTxId(): nat {
// Incrementing the transaction ID
state.txid += 1n;
// Returning the new transaction ID
return state.txid;
}
Starting a local replica
Before you can deploy the project's canisters, you'll need to assure that a local replica is running with the command:
dfx start --clean
Deploying the project's canister
Now that you've reviewed the canister's source code, you can now move onto creating your NFT token. To create your NFT, you will need an image file that will be used as the NFT's logo. Save this image file in the project's directory as the name NFT_logo.png
.
Then, you can deploy the canister with the following initialization arguments:
dfx deploy backend --argument "(
principal\"$(dfx identity get-principal)\",
record {
logo = record {
logo_type = \"NFT_logo/png\";
data = \"\";
};
name = \"Dev Journey NFT\";
symbol = \"DJNFT\";
}
)"
What this command does
-
principal
: Refers to the initial custodian of the NFT. A custodian is a user who has the permissions to administrate the NFT. -
"$(dfx identity get-principal)"
: This string automatically interpolates the current identity used bydfx
when this command was executed. In this tutorial, that identity will be theDevJourney
identity. -
logo
: Refers to the image file that represents the NFT. -
name
: Refers to the string of text used as the name of the NFT. -
symbol
: Refers to a short, unique string of characters used to identity the NFT tokens. A short, unique symbol to identify the token.
After running this command, you will receive output that resembles the following:
You will receive output that resembles the following:
URLs:
Backend canister via Candid interface:
backend: http://127.0.0.1:8000/?canisterId=be2us-64aaa-aaaaa-qaabq-cai&id=bd3sg-teaaa-aaaaa-qaaba-cai
Minting an NFT
Now that your canister is deployed, you can interact with it to mint your first NFT. Use the following command to call the canister's method mint
:
dfx canister call backend mint \
"(
principal\"$(dfx identity get-principal)\",
vec {
record {
data = blob\"Developer Journey NFT\";
key_val_data = vec {
record { key = \"description\"; val = variant{TextContent=\"The NFT metadata can hold arbitrary metadata\"}; };
record { key = \"tag\"; val = variant{TextContent=\"education\"}; };
record { key = \"contentType\"; val = variant{TextContent=\"text/plain\"}; };
record { key = \"locationType\"; val = variant{Nat8Content=4:nat8} };
}
}
}
)"
What this command does
This command makes a call to the canister's mint
method and passes the following parameters to the method:
-
description
: An string of text that describes the NFT's arbitrary metadata. -
tag
: A string of text used to tag the NFT. Tags are typically used by search engines to help index search results. -
contentType
: Refers to the type of content being minted into the NFT; in this example, the string 'Developer Journey NFT' is being passed to the method to be minted, so thecontentType
oftext/plain
is set. -
locationType
: Refers to anat8
numerical value that is used to describe the NFT's location.
If successful, this command will return output that resembles the following:
(variant { Ok = record { id = 1 : nat; token_id = 0 : nat64 } })
Transferring an NFT
The DIP721 standard supports transferring an NFT to another principal value through the use of the transferFromDip721
and safeTransferFromDip721
methods, in this tutorial it is safeTransferFrom
method.
To transfer an NFT to another identity, first you will need a second identity which will become the identity that receives the transferred NFT. Create another identity with the command:
dfx identity new NftTransfer
Then, create an environment variable that will store this new identity's principal value:
NFT_TRANSFER=$(dfx --identity NftTransfer identity get-principal)
You can verify that this environment variable was set correctly by printing its value with the echo
command:
echo $NFT_TRANSFER
This should return the NftTransfer identity's principal.
Now, to transfer the NFT from your DevJourney
identity to the NftTransfer
identity, use the following command that calls the backend
canister's method safeTransferFrom
:
dfx canister call backend safeTransferFrom "(principal\"$(dfx identity get-principal)\", principal\"$NFT_TRANSFER\", 0)"
In this command, the safeTransferFrom
method is used to transfer the NFT with token ID of 0
from the current user's principal (defined by "$(dfx identity get-principal)\"
), to the principal stored in the environment variable NFT_TRANSFER
.
If successful, this command will return output that resembles the following:
(variant { Ok = 2 : nat })
To transfer the NFT back to the original user identity, you can use the same command but with the opposite order of principals:
dfx canister call backend safeTransferFrom "(principal\"$NFT_TRANSFER\", principal\"$(dfx identity get-principal)\", 0)"
This second transfer works since the identity making the call, your DevJourney
identity. This command will return output that resembles the following:
(variant { Ok = 3 : nat })
Querying the balance of NFTs for your user principal
To query the balance of NFTs that your user principal owns, you can make a call to the canister's balanceOf
method with the command:
dfx canister call backend balanceOf "(principal\"$(dfx identity get-principal)\")"
For this tutorial, the output of this command should return a balance of 1
:
(1 : nat64)
Querying the maximum amount of minted NFTs
To query the maximum amount of minted NFTs, you can make a call to the canister's totalSupply
method with the command:
dfx canister call backend totalSupply
In this tutorial, the output of this command should return the number of minted NFTs recorded.
Querying the NFT's metadata
To query the NFT's metadata information, first you will need the token's ID. This was returned in the output of the command used when you minted the NFT with the mint
method:
(variant { Ok = record { id = 1 : nat; token_id = 0 : nat64 } })
In this example, the token ID is 0
. Then, use this token ID in the following command to call the canister's getMetadata
method:
dfx canister call backend getMetadata "0"
This command will return the metadata for the NFT with token ID 0
:
(
variant {
Ok = vec {
record {
data = blob "Developer Journey NFT";
key_val_data = vec {
record {
key = "description";
val = variant {
TextContent = "The NFT metadata can hold arbitrary metadata"
};
};
record { key = "tag"; val = variant { TextContent = "education" } };
record {
key = "contentType";
val = variant { TextContent = "text/plain" };
};
record {
key = "locationType";
val = variant { Nat8Content = 4 : nat8 };
};
};
};
}
},
)
Querying the NFT's information
To query the NFT's logo image, you can make a call to the canister's logo
method using the command:
dfx canister call backend logo
This command will return the NFT logo's image file:
(record { data = ""; logo_type = "NFT_logo/png" })
To query the NFT's name, you can make a call to the name
method using the command:
dfx canister call backend name
This will return the NFT's name that was set when you created the NFT canister:
("Dev Journey NFT")
To query the NFT's symbol, you can make a call to the symbol
method using the command:
dfx canister call backend symbol
This will return the NFT's symbol:
("DJNFT")
Querying the owner of a specific token ID
To get the principal ID that owns a specific NFT token ID, you can query the ownerOf
method with the token ID by running the following command:
dfx canister call backend ownerOf "0"
This will return your user principal:
(
variant {
Ok = principal "5wuse-ejxao-gkqq6-4dhl5-hn5ps-2mgop-2se4s-w4zle-agr6j-svlhq-3qe"
},
)
You can verify that this is the same principal that you used to mint the NFT by querying principal ID of the local developer identity currently used:
dfx identity get-principal
Resources
Need help?
Did you get stuck somewhere in this tutorial, or feel like you need additional help understanding some of the concepts? The ICP community has several resources available for developers, like working groups and bootcamps, along with our Discord community, forum, and events such as hackathons. Here are a few to check out:
-
Developer Discord community, which is a large chatroom for ICP developers to ask questions, get help, or chat with other developers asynchronously via text chat.
-
Weekly developer office hours to ask questions, get clarification, and chat with other developers live via voice chat. This is hosted on our developer Discord group.