back to Dfinity

04 — The NFT canister

The NFT Canister

This chapter includes a substantial amount of Motoko code! The language is fairly straightforward and can sometimes be compared to Swift, we will explain several concepts and give as much insight as possible. However if you want to have an even deeper understanding, take a look at the Official Motoko Guide.

The NFT canister is responsible for handling NFT minting, ownership and transactions. Our implementation is an extension of ic-nft. We made the following adjustments:

  • Switched the definition from an actor class to an actor. We will explain the difference in the following chapter.
  • Defined init and setOwner due to the fact that we changed the actor definition. They behavior will also be explained in the following paragraphs.
  • Defined mintNftForMyself function to allow a user to mint an NFT to his own account without having to be the owner of the canister.

Actors and Actor classes

Motoko abstracts the complexity of the Internet Computer (IC) with a well known, higher-level abstraction: the actor model. Each canister is represented as a typed actor. The type of an actor lists the messages it can handle. Each message is abstracted as a typed, asynchronous function. A translation from actor types to Candid types imposes structure on the raw binary data of the underlying IC. An actor is similar to an object, but is different in that its state is completely isolated, its interactions with the world are entirely through asynchronous messaging, and its messages are processed one-at-a-time, even when issued in parallel by concurrent actors. If you wish to read a more in-depth documentation about the actors, you can find it here.

We can declare an actor either directly or we can generalize its definition with an actor class.

For instance, we define an actor like this:

actor Strawberry {
    public func getName() : async Text {
        "Strawberry"
    }
}

And (assuming we added this actor to the dfx.json) we could deploy it with:

dfx deploy strawberry

And then calling getName would return us the value "Strawberry".

Say that we want to define more fruits. Motoko allows us to generalize the actor with a class:

actor class Fruit(_name : Text) {
    public func getName() : async Text {
        _name;
    }
}

We would deploy it with a command like this:

dfx deploy fruit "(\"Strawberry\")"

Here we are passing "Strawberry" as an argument to the actor class Fruit. Calling getName will return us the value "Strawberry".

Caveats with importing actor classes

In Motoko, we can either import an actor from a local file like in this example or from another deployed canister, like shown in this example. Importing from a local file is very convenient, but the methods of the actor used internally will not be available to all the canisters on the network.

Since we want our NFT canister to be accessible to all the other canisters on the network, we need to deploy it and use the second method of importing. But here is another issue: actor classes cannot be imported via canister import! Sadly, this means we have to renounce using an actor class for the NFT canister. This is the main reason why we redefined it to a simple actor declaration. We also added the init function to compensate for the missing constructor. We will go into more detail in the following paragraphs.

Canister Declaration

Let's open src/nft/main.mo and look at its code!

main.mo is the entry point of the canister. We specified it in dfx.json. An entry point has to contain either an actor or an actor class definition.

We define our actor like this:

actor NFToken {
    ...
}

We declare some types internally in order to make it easier to reference them inside the actor:

type Metadata = Types.Metadata;
...

We then declare the following public types:

// responses that can be sent to the client in case of an error
public type Errors = {
    #Unauthorized;
    #TokenNotExist;
    #InvalidOperator;
};

// response that will be sent to the client after a transaction
public type TxReceipt = {
    #Ok: Nat;
    #Err: Errors;
};

// response that will be sent to the client after a mint
public type MintResult = {
    #Ok: (Nat, Nat);
    #Err: Errors;
};

The types we just declared are called variant types. They resemble the concept of enum in traditional programming languages, but they are more powerful since they allow to perform pattern matching and improve type safety. They are very similar to Swift enums and Rust enums as each case/variant can define some parameters.

Another important type is MintPayload and it can be found in types.mo:

public type MintPayload = {
    payload : Blob;
    contentType : Text;
    isPrivate : Bool;
    owner : ?Principal;
};

As you can see, the payload is of type Blob. It contains the data of the entire asset and it will be stored in the canister's persistent memory.

In the main actor we declare the following variables:

private stable var logo_ : Text = ""; // base64 encoded image
private stable var name_ : Text = "Kryha";
private stable var symbol_ : Text = "KR4";
private stable var desc_ : Text = "Kryha NFT";
private stable var owner_: Principal = Principal.fromText("aaaaa-aa");
private stable var totalSupply_: Nat = 0;
private stable var blackhole: Principal = Principal.fromText("aaaaa-aa");

private stable var tokensEntries : [(Nat, TokenInfo)] = [];
private stable var usersEntries : [(Principal, UserInfo)] = [];
private var tokens = HashMap.HashMap<Nat, TokenInfo>(1, Nat.equal, Hash.hash);
private var users = HashMap.HashMap<Principal, UserInfo>(1, Principal.equal, Principal.hash);
private stable var txs: [TxRecord] = [];
private stable var txIndex: Nat = 0;

The stable keyword indicates that the value is persistent and won't be reset after the canister is restarted or upgraded.

  • logo_ should be set to the base64 encoded token logo. We are not making any use of that at the moment, so we just set it to an empty text value.
  • name_, symbol_ and desc_ are variables describing our token. We are using the name of our company but you can set them to the values you prefer.
  • totalSupply_ is a counter that tracks the amount of minted NFTs.
  • blackhole is a principal set to "aaaaa-aa" which is the id of the Management Canister.
  • owner_ defaults to the id of the Management Canister, but it should be redefined by calling the init function right after the canister has been deployed. Later we will show you how we set it to the Backend Canister principal.
  • tokensEntries is an array containing tuples composed by a token's id and its metadata.
  • usersEntries is an array containing tuples composed by a principal (identifying a user) and the user's metadata.
  • tokens is a hash map that uses token ids as keys and token metadata as values. Its initialization is based on the values inside tokensEntries
  • users is a hash map that uses principals as keys and user metadata as values. Its initialization is based on the values inside usersEntries
  • txs is an array that will keep track of all the transactions.
  • txIndex counts all the transactions.

tokens and users are very important hash maps that are being used to track and access token ownership in a very optimized way (the time complexity of accessing and inserting an item in a hash map is O(1) on average), but they cannot be declared as stable! We need to define some system functions that will be called during canister upgrade in order to repopulate those hash maps based on the data we have in our stable variables. They are declared at the bottom of the actor:

system func preupgrade() {
    usersEntries := Iter.toArray(users.entries());
    tokensEntries := Iter.toArray(tokens.entries());
};

system func postupgrade() {
    type TokenInfo = Types.TokenInfo;
    type UserInfo = Types.UserInfo;

    users := HashMap.fromIter<Principal, UserInfo>(usersEntries.vals(), 1, Principal.equal, Principal.hash);
    tokens := HashMap.fromIter<Nat, TokenInfo>(tokensEntries.vals(), 1, Nat.equal, Hash.hash);
    usersEntries := [];
    tokensEntries := [];
};

In the following paragraphs we will have a look at the owner setup and the main features of the canister: minting and transfers.

Setting an Owner

The owner_ variable keeps track of the principal that can perform restricted operations (in our case, it will be the backend canister). To properly setup an owner, we have to call the init function right after deployment. As explained above, we have to define and call the init function to compensate for the missing constructor, since our case doesn't allow us to use actor classes.

public func init(owner: Principal): async () {
    assert(owner_ == blackhole);
    owner_ := owner;
};

Calling assert will make the function trap if the condition is false, thus init is only callable once.

In the deployment paragraph, you'll see that we initialize the owner to the principal of the backend canister. If in the future we want to change that owner, we can use the following function:

public shared(msg) func setOwner(new: Principal): async Principal {
    assert(msg.caller == owner_);
    owner_ := new;
    new
};

assert makes sure the entity that made the call is the owner, making this call restricted to that particular principal.

Minting an NFT

For a user to mint their own NFTs, we implement the following function:

// 1
public shared({ caller }) func mintForMyself(payload : MintPayload) : async MintResult {
    // 2
    let metadata : TokenMetadata = {
        attributes = [{ key = "isPrivate"; value = Bool.toText(payload.isPrivate); }];
        filetype = payload.contentType;
        location = #InCanister(payload.payload)
    };

    // 3
    let token: TokenInfo = {
        index = totalSupply_;
        var owner = caller;
        var metadata = ?metadata;
        var operator = null;
        timestamp = Time.now();
    };

    tokens.put(totalSupply_, token); // 4
    _addTokenTo(caller, totalSupply_); // 5
    totalSupply_ += 1; // 6
    let txid = addTxRecord(caller, #mint(?metadata), ?token.index, #user(blackhole), #user(caller), Time.now()); // 7
    return #Ok((token.index, txid)); // 8
};

Let's examine it step by step:

  1. We declare the function as public since we want it to be callable by an external entity. The function will return an object of type MintResult to the caller. The payload is an object of type MintPayload, described in the previous paragraph.
  2. We declare the metadata object based on the data from the provided payload. For now, we only want to support assets stored in canister, so we set the location field to that variant.
  3. We create the token object and assign the metadata object to its field. We set the owner field to the value of the caller principal.
  4. We add the token to the tokens hash map.
  5. We add the token to the tokens owned by the caller.
  6. We increment the total supply.
  7. We store the transaction record.
  8. The function returns a successful message carrying the token index and transaction id.

Transferring NFTs

We define two functions that handle NFT transfer: one will transfer the token to a specified id and the other will transfer it to the owner account (which in our case will be the backend canister).

The first function is defined like this:

// 1
public shared(msg) func transfer(to: Principal, tokenId: Nat): async TxReceipt {
    // 2
    var owner: Principal = switch (_ownerOf(tokenId)) {
        case (?own) {
            own;
        };
        case (_) {
            return #Err(#TokenNotExist)
        }
    };
    if (owner != msg.caller) {
        return #Err(#Unauthorized);
    };

    _clearApproval(msg.caller, tokenId); // 3
    _transfer(to, tokenId); // 4
    let txid = addTxRecord(msg.caller, #transfer, ?tokenId, #user(msg.caller), #user(to), Time.now()); // 5
    return #Ok(txid); // 6
};

Let's have a look at the steps:

  1. The function takes two params: the first one is the principal identifying the receiver of the token and the second one is the id of the token we want to send. The function will return an object of type TxReceipt to the caller.
  2. We check if the token exists and if the caller is the owner of the token. In case it's not the owner, the function will return an error variant.
  3. We approve the transfer for the specified token id.
  4. We execute the transfer.
  5. We store the new transaction record.
  6. The function returns a success variant to the caller.

The second function is defined like this:

public shared({ caller }) func transferToAuction(tokenId : Nat) : async TxReceipt {
    let owner: Principal = switch (_ownerOf(tokenId)) {
        case (?own) {
            own;
        };
        case (_) {
            return #Err(#TokenNotExist)
        };
    };
    if (owner != caller) {
        return #Err(#Unauthorized);
    };
    _clearApproval(caller, tokenId);
    _transfer(owner_, tokenId);
    let txid = addTxRecord(caller, #transfer, ?tokenId, #user(caller), #user(owner_), Time.now());
    return #Ok(txid);
};

This second function operates like the first one, but takes only the token id as a parameter and uses the actor owner as the receiver. This will be useful when we'll want to escrow the NFT to the backend canister during an auction.

Deploying the Canister

We will illustrate how to deploy the canister in the next chapter, since we also need the backend canister for that process to work correctly.