06 — The Frontend Canister
Next read
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 callinggenerateActors
. 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 ourAuth
instance and dispatch the authentication status to our Redux store. - The
handleLogin
function will calllogin
and update the store with the new status. - The
handleLogout
acts the same way ashandleLogin
. - 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 theuseAuth
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 theuseAuth
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 thecatch
. - 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
andledger
actors via theuseAuth
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 thecatch
. - 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 thecatch
. - 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.