back to Dfinity

05 — The Backend Canister

The Backend Canister

The Backend Canister keeps track of the ongoing auctions, bids and handles all the functionalities related to them.

Main Types

Open src/backend/types.mo and let's take a look at the module.

First, we declare the types that will be used for our persistent memory:

public type DB = {
  auctions: AuctionDb;
  bids: BidDb;
  users: UserDb;
};

public type AuctionDb = [(AuctionId, AuctionState)];
public type BidDb = [(BidId, BidObject)];
public type UserDb = [(Principal, UserState)];

DB is a record that contains arrays of all the auctions, bids and users. AuctionDb, BidDb, UserDb are arrays that contain tuples with an id and a record describing the entity.

Let's have a look at the types related to auctions:

public type AuctionStatus = {
  #pending;
  #active;
  #closed;
};

public type AuctionObject = {
  id: AuctionId;
  name: Text;
  description: Text;
  startPrice: Float;
  minIncrement: Float;
  durationInDays: Nat;
  buyNowPrice: Float;
  dateCreated: Timestamp;
  owner: Principal;
  status: AuctionStatus;
  nftId: NftId;
};

public type AuctionState = {
  auction: AuctionObject;
  bidIds: [BidId];
  highestBid: ?BidObject;
};

public type BiddedAuctionState = {
  auction: AuctionObject;
  bidIds: [BidId];
  highestBid: ?BidObject;
  userBid: BidObject;
};
  • AuctionStatus is a variant type that indicates if an auction is pending, active or closed.
  • AuctionObject is a record that contains all the information describing an auction. Let's take a look at the fields that might be trickier to understand:
    • startPrice a value indicating the starting price of the auction.
    • minIncrement the minimum amount that has to be added to overbid the current highest bid.
    • buyNowPrice the price to directly buy the NFT.
    • nftId a natural number indicating the id of the NFT that has been put to auction.
  • AuctionState is a record that contains information describing an auction and its bids. It has the following fields:
    • auction is the record describing the main auction data.
    • bidIds is an array listing the ids of all the bids that have been placed on the specified auction.
    • highestBid can either be null or a record that contains information about the current highest bid.
  • BiddedAuctionState is a record that is very similar to AuctionState, but also contains a field named userBid. It will be useful later in a particular function.

Now let's take a look at the types related to bids:

public type BidObject = {
  amount: Float;
  bidDate: Timestamp;
  bidder: Principal;
  auctionId: AuctionId;
  id: BidId;
  confirmed: Bool;
};

The types related to users are defined as follows:

public type UserState = {
  auctionIds: [AuctionId];
  bidIds: [BidId];
};

The following types will be required as arguments during bid creation, auction creation and activation:

public type NewAuctionPayload = {
  name: Text;
  description: Text;
  startPrice: Float;
  minIncrement: Float;
  durationInDays: Nat;
  buyNowPrice: Float;
  nftId: NftId;
};

public type ActivateAuctionPayload = {
  auctionId: AuctionId;
  nftId: NftId;
};

public type NewBidPayload = {
  amount: Float;
  auctionId: AuctionId;
};

Finally, let's define the different errors our canister will return:

public type Error = {
  #Internal;
  #Unauthorized;
  #NotFound;
  #NotInitialized;
  #NotAllowed;
  #InvalidRequest;
  #InsufficientBalance;
  #InsufficientAllowance;
  #TokenNotExist;
  #InvalidOperator;
  #TransferError;
};

This is a variant type that specifies all the different errors our actor can return to a caller.

Importing Canisters

Open src/backend/main.mo and let's take a look at the code.

Notice these imports:

import NFT "canister:nft";
import Ledger "canister:ledger";

Here we are using canister imports in order to be able to communicate with the canisters deployed on our network through the functions they expose.

Storage and Initialization

We declare the actor:

actor AuctionController {
  ...
}

We declare some stable variables. They will persist across canister updates and thus act as our database:

stable var auctionId: Nat = 0;
stable var bidId: Nat = 0;
stable var DB: T.DB = {
  auctions: T.AuctionDb = [];
  bids: T.BidDb = [];
  users: T.UserDb = [];
};
  • auctionId will be the id of the newest auction we create, thus it also acts as a counter
  • bidId will be the id of the newest bid we create
  • DB is a record that contains arrays of all the auctions, bids and users

In order to rapidly access auctions and bids, we declare some hash maps that will map them to their owner and are initialized based on the values present in DB:

var auctionMap: T.AuctionMap = HashMap.fromIter<T.AuctionId, T.AuctionState>(DB.auctions.vals(), DB.auctions.size(), eq, Hash.hash);
var bidMap: T.BidMap = HashMap.fromIter<T.BidId, T.BidObject>(DB.bids.vals(), DB.bids.size(), eq, Hash.hash);
var userMap: T.UserMap = HashMap.fromIter<T.UserId, T.UserState>(DB.users.vals(), DB.auctions.size(), Principal.equal, Principal.hash);

New auctions, bids and users will only added to the hash maps, so if we want to make the data persistent, we need to declare the following system function:

system func preupgrade() {
  DB := {
    auctions: T.AuctionDb = Iter.toArray(auctionMap.entries());
    bids: T.BidDb = Iter.toArray(bidMap.entries());
    users: T.UserDb = Iter.toArray(userMap.entries());
  };
};

This function will copy the data from the volatile hash maps to the persistent DB before an upgrade is executed. On the next run the hash maps will also contain the old values since their initialization is based on DB.

Creating a new Auction

The function that takes care of creating a new auction is the following:

// 1
public shared({ caller }) func newAuction(auctionData : T.NewAuctionPayload) : async Result<(), T.Error> {
  if (not Util.isAuth(caller)) return #err(#Unauthorized); // 2

  // 3
  let nftOwner = await NFT.ownerOf(auctionData.nftId);
  if (nftOwner != caller) return #err(#NotAllowed);

  // 4
  let auctionObject: T.AuctionObject = {
    id = auctionId;
    name = auctionData.name;
    description = auctionData.description;
    startPrice = auctionData.startPrice;
    minIncrement = auctionData.minIncrement;
    durationInDays = auctionData.durationInDays;
    buyNowPrice = auctionData.buyNowPrice;
    dateCreated = Time.now();
    owner = caller;
    status = #pending;
    nftId = auctionData.nftId;
  };
  let auctionState: T.AuctionState = {
    auction = auctionObject;
    bidIds = [];
    highestBid = null;
  };

  auctionMap.put(auctionId, auctionState); // 5

  // 6
  switch (userMap.get(caller)) {
    case (null) {
      userMap.put(caller, {
        auctionIds = [auctionId];
        bidIds = [];
      });
    };
    case (?userState) {
      userMap.put(caller, {
        auctionIds = Array.append(userState.auctionIds, [auctionId]);
        bidIds = userState.bidIds;
      });
    };
  };

  auctionId += 1; // 7
  #ok(); // 8
};
  1. The function is public, takes one parameter of type NewAuctionPayload and returns either an empty success result or an error.
  2. Checks if the caller is authenticated.
  3. Performs an ownerOf call to the NFT canister in order to check if the authenticated caller is the owner of the NFT they want to put to auction.
  4. Creates the auction state record with the data provided in the payload. The creation date is set to the current time, the status defaults to #pending and the owner of the auction will be the current caller.
  5. Adds the current auction to the hash map containing all the auctions.
  6. If a state for the current caller does not exist, creates a new user state containing the auctionId, else it adds the auctionId to their already initialized user state.
  7. Increments the value of auctionId. The next auction will have that value.
  8. Returns an empty success response to the caller.

A newly created auction will be in #pending status, in order to be able to set it to #active we define the following function:

// 1
public shared({ caller }) func activateAuction(payload : T.ActivateAuctionPayload) : async Result<(), T.Error> {
  if (not Util.isAuth(caller)) return #err(#Unauthorized); // 2

  // 3
  switch (auctionMap.get(payload.auctionId)) {
    case (null) return #err(#NotFound);
    case (?{ auction; bidIds; highestBid }) {
      // 4
      if (auction.owner != caller or auction.status != #pending or auction.nftId != payload.nftId) return #err(#NotAllowed);

      // 5
      let isTokenMine = await NFT.isTokenMine(auction.nftId);
      if (not isTokenMine) return #err(#NotAllowed);
      
      // 6
      auctionMap.put(payload.auctionId, {
        highestBid;
        bidIds;
        auction = {
          id = auction.id;
          name = auction.name;
          description = auction.description;
          startPrice = auction.startPrice;
          minIncrement = auction.minIncrement;
          durationInDays = auction.durationInDays;
          buyNowPrice = auction.buyNowPrice;
          dateCreated = auction.dateCreated;
          owner = auction.owner;
          status = #active;
          nftId = auction.nftId;
        };
      });
      #ok(); // 7
    };
  }
};
  1. The function is public, takes one parameter of type ActivateAuctionPayload and returns either an empty success result or an error.
  2. Checks if the caller is authenticated.
  3. Retrieves the auction with the specified id and if it's not found returns a #NotFound error.
  4. Checks if the caller is allowed to activate the specified auction.
  5. Performs a call to the NFT canister and checks if the caller is the owner of the NFT.
  6. Updates the status of the stored auction record to #active. Sadly, Motoko doesn't have a spread operator yet, so we have to redefine the entire record.
  7. Returns an empty success message to the caller.

Creating a new Bid

Let's have a look at the function that handles bid creation:

// 1
public shared({ caller }) func bid(bid : T.NewBidPayload): async Result<T.BidObject, T.Error> {
  if (not Util.isAuth(caller)) return #err(#Unauthorized); // 2
  let callerAccount = Account.defaultAccountIdentifier(caller); // 3

  // 4
  switch(auctionMap.get(bid.auctionId)) {
    case (null) return #err(#NotFound);
    case (?auction) {
      // 5
      if (caller == auction.auction.owner or auction.auction.status != #active) return #err(#NotAllowed);

      // 6
      let bids: [T.BidObject] = filterBidsByAuctionId(bid.auctionId, bidMap, auctionMap);

      // 7
      var isValid: Bool = validateBid(bid, bids, auction.auction);
      if (not isValid) return #err(#InvalidRequest);

      // 8
      let auctionExpiration = Util.addDays(auction.auction.dateCreated, auction.auction.durationInDays);
      if (Time.now() >= auctionExpiration) return #err(#NotAllowed);

      // 9
      let presentBalance = await Ledger.account_balance({ account = callerAccount });
      let tokenAmount = Util.tokenAmountToNat64(Util.dollarsToToken(bid.amount));
      if (presentBalance.e8s < tokenAmount) return #err(#InsufficientBalance);

      // 10
      // Return tokens to previous highest bidder
      switch (DbUtil.getHighestBid(bids)) {
        case (null) {};
        case (?highestBid) {
          // 11
          let highestBidderAccount = Account.defaultAccountIdentifier(highestBid.bidder);
          let tokenAmount = Util.tokenAmountToNat64(Util.dollarsToToken(highestBid.amount));

          // 12
          let ledgerRes = await Ledger.transfer({
            memo = 0;
            from_subaccount = null;
            to = highestBidderAccount;
            amount = { e8s = tokenAmount };
            fee = { e8s = Const.LEDGER_FEE };
            created_at_time = null;
          });

          // 13
          switch (ledgerRes) {
            case (#Err(error)) return #err(#TransferError);
            case (#Ok(_)) {};
          };
        };
      };

      // 14
      let bidObject: T.BidObject = {
        auctionId = bid.auctionId;
        amount = bid.amount;
        bidDate = Time.now();
        bidder = caller;
        id = bidId;
        confirmed = false;
      };

      // Update Bid Map
      bidMap.put(bidId, bidObject);

      // Update Auction Map
      let updatedAuctionState: T.AuctionState = {
        auction = auction.auction;
        bidIds: [T.BidId] = Array.append(auction.bidIds, [bidObject.id]);
        highestBid = ?bidObject;
      };
      ignore auctionMap.replace(bid.auctionId, updatedAuctionState);

      // Update User Map
      switch(userMap.get(caller)) {
        case (null) {
          userMap.put(caller, {
            bidIds = [bidId];
            auctionIds = [];
          });
        };
        case (?userState) {
          userMap.put(caller, {
            bidIds = Array.append(userState.bidIds, [bidId]);
            auctionIds = userState.auctionIds;
          });
        };
      };

      bidId += 1; // 15
      #ok(bidObject); // 16
    };
  };
};
  1. The function takes one argument of type BidCreationPayload and returns either an error or a record of type BidObject.
  2. Checks if the caller is authenticated.
  3. Calculates the default account identifier of the caller principal. The account identifier denotes an account on the Ledger canister.
  4. Retrieves the auction specified in the bid payload and returns an error if not found.
  5. Checks if the caller is allowed to place a bid on the auction.
  6. Retrieves an array of all the bids already placed on the specified auction.
  7. Checks if the data of the current bid is valid.
  8. Checks if the auction is still ongoing.
  9. Queries the Ledger canister in order to retrieve the current ICP balance of the caller to check if it is high enough.
  10. Retrieves the current highest bid and if present proceeds to refund that.
  11. Retrieves the default account identifier of the current highest bidder.
  12. Performs a transfer call to the Ledger canister in order to reimburse the previous highest amount to the previous highest bidder.
  13. Returns an error to the caller if the transfer failed.
  14. Creates the new bid record and adds it all the required hash maps.
  15. Increments bidId. The next created bid will have the new value as its id.
  16. Returns a success message carrying the newly created bid record.

Closing an Auction

The following function handles the closing process of an auction:

// 1
public shared({ caller }) func closeAuction(auctionId : T.AuctionId) : async Result<(), T.Error> {
  if (not Util.isAuth(caller)) return #err(#Unauthorized); // 2

  // 3
  switch (auctionMap.get(auctionId)) {
    case (null) return #err(#NotFound);
    case (?{ auction; bidIds; highestBid; }) {
      if (caller != auction.owner) return #err(#NotAllowed); // 4

      let auctionExpiration = Util.addDays(auction.dateCreated, auction.durationInDays); // 5
      let bids = filterBidsByAuctionId(auctionId, bidMap, auctionMap); // 6

      switch (highestBid) {
        case (null) {
          if (Time.now() < auctionExpiration) return #err(#NotAllowed); // 7

          // 8
          auctionMap.put(auctionId, {
            highestBid;
            bidIds;
            auction = {
              id = auction.id;
              name = auction.name;
              description = auction.description;
              startPrice = auction.startPrice;
              minIncrement = auction.minIncrement;
              durationInDays = auction.durationInDays;
              buyNowPrice = auction.buyNowPrice;
              dateCreated = auction.dateCreated;
              owner = auction.owner;
              status = #closed;
              nftId = auction.nftId;
            };
          });

          return #ok(); // 9
        };
        case (?highestBid) {
          // 10
          if (Time.now() < auctionExpiration and highestBid.amount < auction.buyNowPrice) return #err(#NotAllowed);

          let tokenAmount = Util.tokenAmountToNat64(Util.dollarsToToken(highestBid.amount)); // 11

          // 12
          switch(await NFT.transfer(highestBid.bidder, auction.nftId)) {
            case (#Err(error)) return #err(#Internal);
            case (#Ok(_)) {}
          };

          // 13
          let ledgerRes = await Ledger.transfer({
            memo = 0;
            from_subaccount = null;
            to = Account.defaultAccountIdentifier(auction.owner);
            amount = { e8s = tokenAmount };
            fee = { e8s = Const.LEDGER_FEE };
            created_at_time = null;
          });

          switch (ledgerRes) {
            case (#Err(error)) return #err(#TransferError);
            case (#Ok(_)) {
              // 14
              auctionMap.put(auctionId, {
                highestBid = ?highestBid;
                bidIds;
                auction = {
                  id = auction.id;
                  name = auction.name;
                  description = auction.description;
                  startPrice = auction.startPrice;
                  minIncrement = auction.minIncrement;
                  durationInDays = auction.durationInDays;
                  buyNowPrice = auction.buyNowPrice;
                  dateCreated = auction.dateCreated;
                  owner = auction.owner;
                  status = #closed;
                  nftId = auction.nftId;
                };
              });
              return #ok(); // 15
            }
          };
        };
      };
    };
  };
};
  1. The function takes one argument of type AuctionId (Nat) and returns an error or an empty success response to the caller.
  2. Checks if the caller is authenticated.
  3. Retrieves the specified auction and returns #NotFound error if no auction with that id exists.
  4. Checks if the caller is the owner of the auction.
  5. Calculates the expiration date by adding the duration to the creation date.
  6. Retrieves an array of the bids placed on that specific auction.
  7. If there is no highest bid, it checks if the expiration date has been reached.
  8. Sets the status of the auction to #closed.
  9. Returns an empty success message to the caller.
  10. An auction can be closed if the expiration date has been reached or if the highest bid is higher than the buyNowPrice, so the function checks for either of those.
  11. Converts the amount to Nat64.
  12. Calls the NFT canister to transfer the escrowed NFT to the highest bidder, if the request fails it returns an #Internal error.
  13. Calls the Ledger canister to transfer the escrowed highest bid amount of ICP to the seller. If the request fails it returns a #TransferError error.
  14. Sets the status of the auction to #closed.
  15. Returns an empty success message to the caller.

Queries

Let's have a look at some of the main queries. Queries never modify local memory and are much faster than a normal transaction. They are useful for fetching information when there is no need to alter the current state of the canister.

The queries we implement are:

  • getAuctionById - retrieves the auction identified by the id provided as an argument.
  • getAuctionList - retrieves all the auctions.
  • getUserPendingAuctions - retrieves all the pending auctions owned by the caller.
  • getBiddedAuctions - retrieves the auctions that have at least one bid placed by the caller.
  • getBidList - retrieves all the bids.
  • getBidsByAuction - retrieves all the bids placed on the specified auction.
  • getUserState - retrieves the user state of the caller.
  • getAccountId - calculates and returns the default account identifier of the specified principal.
  • canisterAccountId - calculates and returns the default account identifier of the backend canister.

Deploying Backend and NFT Canisters

Now that we know how both NFT and Backend canisters work, let's see how to deploy them!

Before continuing, make sure that your local dfx network is running and both Internet Identity and Ledger canisters are already deployed.

Deploy the NFT canister with the following command:

dfx deploy --no-wallet nft

Deploy the backend canister:

dfx deploy --no-wallet backend

Your canisters are deployed and running, but first we need to initialize the NFT canister in order to set backend's principal as its owner.

Retrieve the backend canister principal:

dfx canister id backend

Copy the result and pass it to the init function of the nft canister:

# replace rkp4c-7iaaa-aaaaa-aaaca-cai with the principal of your own backend canister
dfx canister call nft init "principal \"rkp4c-7iaaa-aaaaa-aaaca-cai\""

Congratulations! The canisters are now at your service.

Take a look at src/declarations/. This directory contains compiled JS and TS code that will allow us to interact with our canisters from the frontend. It will be as easy as importing the functions from these files. In the next chapter we'll explore how the frontend calls these functions.