back to Dfinity

06 — The Frontend Canister

The Frontend Canister

The frontend is implemented with the React framework. We assume that you already have knowledge of the framework and we will only focus on illustrating the functionalities related to Dfinity.

The frontend canister hosts the web application written in Typescript. It communicates with the other canisters through the functions declared in src/declarations/. In this chapter we will illustrate how it interacts with the other canisters on our network.

Types

declarations contains TS type definitions based on the Candid interface types. They are very useful, but we decided to redefine them a bit in order to facilitate frontend development.

Take a look at src/types/bid.ts for instance:

import { BidObject as BidObjectBackend, NewBidPayload } from "../declarations/backend/backend.did";
import { Timestamp } from ".";

export interface BidObject extends Omit<BidObjectBackend, "bidDate" | "id" | "bidder" | "auctionId"> {
  id: number;
  auctionId: number;
  bidDate: Timestamp;
  bidder: string;
}

export interface NewBidFormData extends Omit<NewBidPayload, "auctionId"> {
  auctionId: number;
  auctionName: string;
}

We are redefining a few fields of BidObject and NewBidPayload so we can work easily on the frontend.

Whenever we need to go from a "backend" type to a "frontend" type, we use mediator functions. Take a look at src/frontend/src/utils/api-mediators.ts. We define the following:

export const mediate = {
  toFront: {
    // ...
    bidObject: (backendBid: BidObjectBackend): BidObject => ({
      ...backendBid,
      bidDate: backToFront.timestamp(backendBid.bidDate),
      id: backToFront.bigInt2Number(backendBid.id),
      bidder: backToFront.principal2String(backendBid.bidder),
      auctionId: backToFront.bigInt2Number(backendBid.auctionId),
    }),
    // ...
  },
  toBack: {
    // ...
    newBidPayload: (formData: NewBidFormData): NewBidPayload => ({
      ...formData,
      auctionId: frontToback.number2BigInt(formData.auctionId),
    }),
    // ...
  },
}

These functions will facilitate the conversion from a backend type to a frontend type or vice-versa.

Other types work in a similar way, take a look at them if you are curious!

Generating the actors

We communicate with other canisters on the network by importing the actors for each canister from declarations. The problem with doing that is that the RPCs will be performed without an authentication. If we want the user to be able to perform authenticated requests we need to interact with the Internet Identity canister and create new actors via the createActor functions imported from declarations.

Open src/frontend/src/util/auth.ts:

// 1
import { AuthClient } from "@dfinity/auth-client";

// 2
import { backend, createActor as createBackendActor } from "../../../declarations/backend";
import { nft, createActor as createNFTActor } from "../../../declarations/nft";
import { ledger, createActor as createLedgerActor } from "../../../declarations/ledger";

/**
 * Provides functionality for handling main authentication operations.
 * WARNING: Do not call these methods directly from a React component. Use useAuth hook instead.
 */
// 3
class Auth {
  // 4
  client?: AuthClient;
  backend = backend;
  nft = nft;
  ledger = ledger;

  // 5
  async init(): Promise<boolean> {
    this.client = await AuthClient.create();
    const isAuth = await this.client.isAuthenticated();
    if (isAuth) this.generateActors();
    return isAuth;
  }

  private warn() {
    console.warn("Auth client not initialized.");
  }

  // 6
  private generateActors() {
    if (!this.client) return;
    const identity = this.client.getIdentity();
    this.backend = createBackendActor(String(process.env.BACKEND_CANISTER_ID), { agentOptions: { identity } });
    this.nft = createNFTActor(String(process.env.NFT_CANISTER_ID), { agentOptions: { identity } });
    this.ledger = createLedgerActor(String(process.env.LEDGER_CANISTER_ID), { agentOptions: { identity } });
  }

  // 7
  login(onSuccess: () => void): void {
    if (!this.client) return this.warn();
    this.client.login({
      onSuccess: () => {
        try {
          this.generateActors();
          onSuccess();
        } catch (error) {
          console.warn("Login error: ", error);
        }
      },
      identityProvider: process.env.NODE_ENV === "production" ? undefined : "http://localhost:8000?canisterId=rwlgt-iiaaa-aaaaa-aaaaa-cai",
    });
  }

  // 8
  logout(onSuccess: () => void): void {
    if (!this.client) return this.warn();
    this.backend = backend;
    this.nft = nft;
    this.ledger = ledger;
    this.client.logout();
    onSuccess();
  }
}

export const auth = new Auth(); // 9

Let's take a look at what happens step by step:

  • We import AuthClient which will allow us to check for authentication status and call the Internet Identity canister.
  • We import the default actors to be utilized when the user is not authenticated and we also import the createActor functions so we are able to regenerate the auction after the user authenticates.
  • We define a class for handling authentication.
  • We define 3 attributes for each actor we are going to interact with and initialize them to their default (unauthenticated) version. We also define an attribute which will be an instance of AuthClient.
  • The init function instantiates the auth client and checks if the user is authenticated. If the user is authenticated it will proceed on regenerating the (authenticated) actors by calling generateActors.
  • generateActors retrieves the user identity from the auth client and uses it to create the actors.
  • The login function will redirect to the Internet Identity canister authentication page and will generate the actors in case of a successful authentication.
  • The logout function resets the actors to their unauthenticated version and logs out the auth client.
  • We create an instance of this class and export it so we use only one instance through the entire application.

If we were to use this instance directly in a react component, we risk rendering some stale data, so we are going to wrap it in a hook. Open src/frontend/src/hooks/auth.ts:

// 1
interface UseAuthReturn {
  login: () => void;
  logout: () => void;
  isAuthenticated: boolean;
  initialized: boolean;
  principalId?: string;
  backend: ActorSubclass<BackendService>;
  nft: ActorSubclass<NFTService>;
  ledger: ActorSubclass<LedgerService>;
}

// 2
export const useAuth = (): UseAuthReturn => {
  // 3
  const dispatch = useDispatch();
  const { initialized, isAuthenticated } = useAuthState();

  // 4
  useEffect(() => {
    const init = async () => {
      const isAuth = await auth.init();
      dispatch(initAuthClient(isAuth));
    };
    if (!auth.client) init();
  }, [dispatch]);

  // 5
  const handleLogin = useCallback(() => {
    auth.login(() => dispatch(login()));
  }, [dispatch]);

  // 6
  const handleLogout = useCallback(() => {
    auth.logout(() => dispatch(logout()));
  }, [dispatch]);

  // 7
  return {
    login: handleLogin,
    logout: handleLogout,
    isAuthenticated,
    initialized,
    principalId: auth.client?.getIdentity().getPrincipal().toString(),
    backend: auth.backend,
    nft: auth.nft,
    ledger: auth.ledger,
  };
};

Let's have a look at this file step by step:

  • We define the return type of our hook. It is going to return functions and flags to handle the login flow, but also the instances of our actors.
  • We declare our hook.
  • The hook accesses our Redux store in order to keep track of updated data.
  • Whenever we render this hook for the first time, it will call the init function of our Auth instance and dispatch the authentication status to our Redux store.
  • The handleLogin function will call login and update the store with the new status.
  • The handleLogout acts the same way as handleLogin.
  • The hook returns information about the authentication status and the authentication functions.

Now we simply have to render a button that will perform our authentication using this hook.

Open src/frontend/src/view/auth-button/auth-button.tsx:

export const AuthButton: FC = () => {
  // 1
  const auth = useAuth();
  const buttonText = auth.isAuthenticated ? text.logout : text.login;

  // 2
  const handleAuth = () => {
    if (auth.isAuthenticated) {
      auth.logout();
    } else {
      auth.login();
    }
  };

  // 3
  return (
    <ButtonBase onClick={() => handleAuth()} customColor={color.white}>
      {buttonText}
    </ButtonBase>
  );
};
  • The component calls the useAuth so it can have access to the authentication logic.
  • handleAuth will look at the authentication status and will either perform the login or the logout flow.
  • The component renders a button that calls handleAuth on click.

Now we have everything we need to communicate with our canisters!

Calling the canisters

Whenever we want to call the RPCs we use the useAuth hook. In src/frontend/src/service/ we define hooks for the operations we want to perform on the canisters.

Minting an NFT

Open src/frontend/src/service/token.ts and take a look at the following hook:

// 1
export const useCreateNft: APIHook = () => {
  const dispatch = useDispatch();

  const { formData } = useCreateTokenState(); // 2

  const { nft } = useAuth(); // 3
  const [loading, setLoading] = useState(false);

  const callback = useCallback(async () => {
    setLoading(true);
    try {
      // 4
      const metadata = {
        title: formData.title,
        category: formData.category,
        description: formData.description,
        link: formData.link,
        nftId: formData.nftId,
      };
      const uint8File = new Uint8Array(formData.file.data);
      const encodedMetadata = encodeMetadata(metadata);
      const payload = [...encodedMetadata, ...uint8File];
      const mintPayload: MintPayload = {
        contentType: formData.file.type,
        owner: [],
        isPrivate: false,
        payload: payload,
      };

      
      const res = await nft.mintForMyself(mintPayload); // 5

      if ("Err" in res) throw res.Err; // 6

      dispatch(resetTokenInputs()); // 7
    } catch (error) {
      console.warn("Create nft failed: ", error);
    }
    setLoading(false);
  }, [nft, dispatch, formData]);

  return [callback, loading]; // 8
};
  • We declare our hook. This hook will be called directly in our components that will need to access the NFT creation functionality.
  • The hook retrieves the compiled form data from the Redux store. In our flow, there is a Redux slice that keeps track of the data we input in our form when creating an NFT.
  • nft actor get accessed through the useAuth hook.
  • The file data gets encoded in a payload that respects the type required by the Candid interface.
  • The callback calls the mintNftForMyself canister function described in chapter 4 and awaits for its response.
  • If the response contains an error, the callback will throw an error that will be handled in the catch.
  • The callback resets the form values on the Redux store.
  • The hook returns the callback and the flag indicating the loading status of the request.

Creating an Auction

Open src/frontend/src/service/auction.ts. Take a look at the following function:

// 1
export const useCreateAuction: APIHook = () => {
  const dispatch = useDispatch();
  const { formData } = useCreateAuctionState(); // 2
  const { backend } = useAuth(); // 3
  const [loading, setLoading] = useState(false);

  const callback = useCallback(async () => {
    setLoading(true);
    try {
      const payload = mediate.toBack.newAuctionPayload(formData); // 4
      const res = await backend.newAuction(payload); // 5

      if ("err" in res) throw res.err;

      dispatch(resetAuctions()); // 6
    } catch (error) {
      console.warn("Create auction failed: ", error);
    }
    setLoading(false);
  }, [backend, dispatch, formData]);

  return [callback, loading]; // 7
};
  • We declare our hook. This hook will be called directly in our components that will need to access the Auction creation functionality.
  • The hook retrieves the compiled form data from the Redux store.
  • backend actor gets accessed through the useAuth hook.
  • We use a mediator function to convert the frontend type to the backend type.
  • The callback calls the newAuction canister function described in chapter 5 and awaits for its response. If the response contains an error it throws and handles it in the catch.
  • The Redux state relative to the form data is reset so the fields will be empty.
  • The hook returns the callback and the flag indicating the loading status of the request.

Bidding

Open src/frontend/src/service/bid.ts. Take a look at the following function:

// 1
export const useCreateBid: APIHook = () => {
  const history = useHistory();
  const dispatch = useDispatch();
  const { backend, ledger } = useAuth(); // 2
  const { formData } = useCreateBidState(); // 3
  const [loading, setLoading] = useState(false);

  const callback = useCallback(async () => {
    setLoading(true);
    try {
      // 4
      const bidPayload = mediate.toBack.newBidPayload(formData);
      const tokens = dollarsToToken(bidPayload.amount);
      const tokenAmount = tokenAmountToInt(tokens);

      const bidRes = await backend.bid(bidPayload); // 5
      if ("err" in bidRes) throw bidRes.err;

      const toAccount = await backend.canisterAccountId(); // 6

      // 7
      const transferParam: TransferArgs = {
        memo: BigInt(1),
        from_subaccount: [],
        to: toAccount,
        amount: { e8s: tokenAmount },
        fee: { e8s: LEDGER_FEE },
        created_at_time: [],
      };

      const transferRes = await ledger.transfer(transferParam); // 8
      if ("Err" in transferRes) throw transferRes.Err;

      const bid = mediate.toFront.bidObject(bidRes.ok); // 9

      dispatch(setHighestBid(bid)); // 10
      history.push(path.dashboard + path.confirmation); // 11
    } catch (error) {
      console.warn("Bid Creation failed: ", error);
    }
    setLoading(false);
  }, [backend, dispatch, formData, history, ledger]);

  return [callback, loading]; // 12
};
  • We declare our hook. This hook will be called directly in our components that will need to access the bidding functionality.
  • The hook accesses the backend and ledger actors via the useAuth hook.
  • The hook accesses the form data related to bid creation from the Redux store.
  • The form data gets converted from the frontend type to the backend type.
  • The callback calls the bid function from the backend canister and awaits for its response. If the response contains an error it gets thrown and handled in the catch.
  • The callback retrieves the backend canister account id by performing a call to the canisterAccountId function. We will escrow the ICP to this account via the ledger canister.
  • We define the argument needed by the transfer function:
    • memo - we don't make use of this field at the moment so we just pass a default value.
    • from_subaccount - we are transferring the funds from the user's default account so we just provide an empty array.
    • to - we transfer the funds to the account of the backend canister.
    • amount - the amount specified by the user in the form.
    • fee - a fixed amount that has to be payed for transfer.
    • created_at_tim - the ledger canister will take care of setting this value, so we just set it to an empty array.
  • The callback calls the transfer function from the ledger canister with the params defined above and awaits for the response. If the response contains an error, it will be thrown and handled in the catch.
  • A mediator function is being used to convert the backend type coming from a successful bid creation message.
  • The highest bid on the Redux store gets updated with the new value.
  • The callback redirects the user to a success page.
  • The hook returns the callback and the flag indicating its loading status.

Deploy the entire network

Now that we've walked you through the main parts of the entire project, let's see how we can deploy it from scratch.

First things first, stop your running network instance and run a clean one with:

dfx start --clean

Now deploy the Internet Identity canister:

make identity

Open dfx.json and change ledger.public.did to ledger.private.did, save the file and run:

make ledger

Change the configuration line back to ledger.public.did and save the file.

Create all the remaining canisters with:

make create

Congratulations! All the canisters should be deployed now. If you are curious, take a look at the Makefile to see what happens in the background.

You can now retrieve the frontend canister id from the logs and access the deployed web application by navigating to localhost:8000?canisterId=<frontend_canister_id>. If you wish to run a development version of the web application, be sure your canisters are deployed and run:

yarn start

Navigate to localhost:8080 to access the application. It will be updated every time you modify and save your code.

If you wish to make some changes to the Motoko canisters, you can redeploy them with:

make upgrade

This will rebuild the WASM bytecode and Candid interfaces and deploy the new versions to the local network.