05 — The Backend Canister
Previous read
Next read
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 benull
or a record that contains information about the current highest bid.
BiddedAuctionState
is a record that is very similar toAuctionState
, but also contains a field nameduserBid
. 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 counterbidId
will be the id of the newest bid we createDB
is a record that contains arrays of all theauctions
,bids
andusers
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
};
- The function is public, takes one parameter of type
NewAuctionPayload
and returns either an empty success result or an error. - Checks if the caller is authenticated.
- Performs an
ownerOf
call to theNFT
canister in order to check if the authenticated caller is the owner of the NFT they want to put to auction. - 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. - Adds the current auction to the hash map containing all the auctions.
- If a state for the current caller does not exist, creates a new user state containing the
auctionId
, else it adds theauctionId
to their already initialized user state. - Increments the value of
auctionId
. The next auction will have that value. - 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
};
}
};
- The function is public, takes one parameter of type
ActivateAuctionPayload
and returns either an empty success result or an error. - Checks if the caller is authenticated.
- Retrieves the auction with the specified id and if it's not found returns a
#NotFound
error. - Checks if the caller is allowed to activate the specified auction.
- Performs a call to the
NFT
canister and checks if the caller is the owner of the NFT. - 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. - 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
};
};
};
- The function takes one argument of type
BidCreationPayload
and returns either an error or a record of typeBidObject
. - Checks if the caller is authenticated.
- Calculates the default account identifier of the caller principal. The account identifier denotes an account on the Ledger canister.
- Retrieves the auction specified in the bid payload and returns an error if not found.
- Checks if the caller is allowed to place a bid on the auction.
- Retrieves an array of all the bids already placed on the specified auction.
- Checks if the data of the current bid is valid.
- Checks if the auction is still ongoing.
- Queries the
Ledger
canister in order to retrieve the current ICP balance of the caller to check if it is high enough. - Retrieves the current highest bid and if present proceeds to refund that.
- Retrieves the default account identifier of the current highest bidder.
- Performs a
transfer
call to theLedger
canister in order to reimburse the previous highest amount to the previous highest bidder. - Returns an error to the caller if the transfer failed.
- Creates the new bid record and adds it all the required hash maps.
- Increments
bidId
. The next created bid will have the new value as its id. - 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
}
};
};
};
};
};
};
- The function takes one argument of type
AuctionId
(Nat
) and returns an error or an empty success response to the caller. - Checks if the caller is authenticated.
- Retrieves the specified auction and returns
#NotFound
error if no auction with that id exists. - Checks if the caller is the owner of the auction.
- Calculates the expiration date by adding the duration to the creation date.
- Retrieves an array of the bids placed on that specific auction.
- If there is no highest bid, it checks if the expiration date has been reached.
- Sets the status of the auction to
#closed
. - Returns an empty success message to the caller.
- 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. - Converts the amount to
Nat64
. - Calls the
NFT
canister to transfer the escrowed NFT to the highest bidder, if the request fails it returns an#Internal
error. - 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. - Sets the status of the auction to
#closed
. - 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.