How to create a Staking Dapp on the ICP Blockchain using React and Motoko

How to create a Staking Dapp on the ICP Blockchain using React and Motoko

Introduction

Staking applications have become increasingly popular in the blockchain ecosystem, allowing users to earn rewards by locking up their cryptocurrency tokens in a smart contract. These applications have revolutionized the way users interact with blockchain networks, providing a new avenue for users to earn passive income while contributing to network security and decentralization. In this article, we will explore how to create your own staking Dapp on the Internet Computer Blockchain using React and Motoko. The Internet Computer Blockchain is a revolutionary blockchain platform that enables developers to build powerful and scalable applications directly on the blockchain. By the end of this guide, you will have the knowledge and skills to build your own staking Dapp, enabling you to join the thriving community of ICP developers and contribute to the growing ecosystem of decentralized applications.

Prerequisites

  • Prior knowledge of Motoko
  • Prior knowledge of front-end development using HTML, CSS, JavaScript and React.
  • dfx installed on your machine
  • Prior knowledge of the command line or Terminal
  • Code editors like VsCode or sublime Text
  • Internet connection
  • Internet Browser

How the Dapp works.

The staking Dapp allows users to deposit their cryptocurrency tokens into a canister, where they can lock them for a desired period of time to earn rewards. The longer users lock their tokens, the greater the rewards they can earn, with up to 8% offered for locking tokens for a full year. At any time, users have the flexibility to withdraw both their locked tokens and the rewards they have accumulated. This Dapp empowers users to earn passive income while contributing to the security and decentralization of the blockchain network. The code for the whole project can be found here

Downloading the project template.

We will use an already made template for our project to save time. This template is already configured for react and Motoko. In your terminal run the following command to clone the repo from GitHub

git clone https://github.com/sam-the-tutor/motoko-react-template.git
cd motoko-react-template
npm run setup

This will clone the project from GitHub, install the necessary dependencies and also deploy the required canisters for our project. Running the command npm start should open the project in a browser and if everything is set up correctly, you should see the following page

Now that we our project up and running, its time to write our backend code for the project. You can open the project in your favorite code editor, I always use VsCode

Backend Development

In this section, we are going to look at the backend set up for our project as well all the code required in the backend

ICRC Ledger Canister setup

The ledger canister allows users to deposit and withdraw tokens from our staking Dapp. In this section, we are going to configure the ledger canister for our entire project

In the dfx.json file replace everything with the code below

{
  "canisters": {
    "backend": {
      "type": "motoko",
      "main": "backend/Backend.mo",
      "declarations": {
        "node_compatibility": true
      }
    },
     "icrc1_ledger_canister": {
      "type": "custom",
      "candid": "https://raw.githubusercontent.com/dfinity/ic/ff10ea1dba07c0f66c66536a46a97146cf260e90/rs/rosetta-api/icrc1/ledger/ledger.did",
      "wasm": "https://download.dfinity.systems/ic/ff10ea1dba07c0f66c66536a46a97146cf260e90/canisters/ic-icrc1-ledger.wasm.gz"
    },
    "frontend": {
      "dependencies": ["backend"],
      "type": "assets",
      "source": ["dist/"]
    }
,"internet_identity": {
      "type": "custom",
      "candid": "https://github.com/dfinity/internet-identity/releases/latest/download/internet_identity.did",
      "wasm": "https://github.com/dfinity/internet-identity/releases/latest/download/internet_identity_dev.wasm.gz",
      "shrink": false,
      "remote": {
        "candid": "internet_identity.did",
        "id": {
          "ic": "rdmx6-jaaaa-aaaaa-aaadq-cai"
        }
      }
    }
  },
  "defaults": {
    "build": {
      "packtool": "npm run --silent sources"
    }
  },
  "output_env_file": ".env",
  "version": 2
}

In the above code, we have added a new canister icrc1-ledger-canister definition. We will use this token canister in our project.

Deploying the icrc1-ledger-canister

To use the token ledger canister, we need to first deploy it. Paste the code below in your terminal to deploy the ledger canister. This code was copied from the Dfinity foundation website. I recommend you take a look at its documentation to understand what we are doing here.

dfx identity new minter
dfx identity use minter
export MINTER=$(dfx identity get-principal)
export TOKEN_NAME="Staking-Token"
export TOKEN_SYMBOL="STT"
dfx identity use default
export DEFAULT=$(dfx identity get-principal)
export PRE_MINTED_TOKENS=10_000_000_000
export TRANSFER_FEE=10_000
dfx identity new archive_controller
dfx identity use archive_controller
export ARCHIVE_CONTROLLER=$(dfx identity get-principal)
export TRIGGER_THRESHOLD=2000
export NUM_OF_BLOCK_TO_ARCHIVE=1000
export CYCLE_FOR_ARCHIVE_CREATION=10000000000000
export FEATURE_FLAGS=true
dfx deploy icrc1_ledger_canister --specified-id mxzaz-hqaaa-aaaar-qaada-cai --argument "(variant {Init =
record {
     token_symbol = \"${TOKEN_SYMBOL}\";
     token_name = \"${TOKEN_NAME}\";
     minting_account = record { owner = principal \"${MINTER}\" };
     transfer_fee = ${TRANSFER_FEE};
     metadata = vec {};
     feature_flags = opt record{icrc2 = ${FEATURE_FLAGS}};
     initial_balances = vec { record { record { owner = principal \"${DEFAULT}\"; }; ${PRE_MINTED_TOKENS}; }; };
     archive_options = record {
         num_blocks_to_archive = ${NUM_OF_BLOCK_TO_ARCHIVE};
         trigger_threshold = ${TRIGGER_THRESHOLD};
         controller_id = principal \"${ARCHIVE_CONTROLLER}\";
         cycles_for_archive_creation = opt ${CYCLE_FOR_ARCHIVE_CREATION};
     };
 }
})"

In the above code, we deploy a token called STT that users will use in our application. Remember, you can chose to use another token forexample ckBTC,ICP among others. The token has an initial supply of 10_000_000_000 and this supply is being held by the principal address belonging to the default identity. This information will be useful to use at a later stage as we start interacting with our token.

Configuring the backend canister

In the dfx.json, replace the backend canister definition with the code below.

  "backend": {
      "type": "motoko",
      "main": "backend/Backend.mo",
      "dependencies": ["icrc1_ledger_canister"],
      "declarations": {
        "node_compatibility": true
      }
    },

In the above code, we tell our backend canister that we will use the token canister in our code and the way we do that is by specifying the token canister as a dependency to the backend canister.

Backend code for the project

In this section, we are going to look at all the code that is necessary for the backend code. Open the Backend.mo file in your code editor. All the backed code will go into this file

import Bool "mo:base/Bool";
import Int "mo:base/Int";
import Nat "mo:base/Nat";
import HashMap "mo:base/HashMap";
import Principal "mo:base/Principal";
import Result "mo:base/Result";
import Time "mo:base/Time";
import Float "mo:base/Float";
import ICRCLedger "canister:icrc1_ledger_canister";

We start by importing all the libaries that we need for our code to work. At the end, you see that we also import our token canister under the name ICPLedger because we will interact with it here in our code.

actor class StakingDapp() = this {


}

Next, we define an empty actor class StakingDapp. The code will go inside this actor class.

 public type Stake = {
    amount : Nat;
    startTime : Int;
  };

  let tokenFee:Nat = 10000;

  let totalTimeInAYear :Int = 31536000000000000;

We defined a public type Stake that with the following properties:

  • amount : This stores the amount of tokens that the user will deposit to stake in our Dapp and is of type Nat.
  • startTime: This stores the time the user deposited the tokens in our dapp. In this way, we can track how long the tokens have been staked.

We define another variable totalTimeInAYear that will store the total time in ayear. This time is in nanoseconds. We chose to use nanoseconds because the time libary we use in motoko also denotes time in nanoseconds.

  let usersHashMap = HashMap.HashMap<Principal, Stake>(0, Principal.equal, Principal.hash);

We define the usersHashMap store. We will use this to store information about the users and any information related to them. The store utilizes the HashMap data type to link the user's principal address to the amount of tokens they have deposited as well as the time of deposit.

Helper functions

 private func transfer_funds_from_user(user : Principal, _amount : Nat) : async Bool {

    let transferResults = await ICRCLedger.icrc2_transfer_from({
      spender_subaccount = null;

      from = {
        owner = user;
        subaccount = null;
      };
      to = {
        owner = Principal.fromActor(this);
        subaccount = null;
      };
      amount = _amount;
      fee = null;
      memo = null;
      created_at_time = null;

    });

    switch (transferResults) {
      case (#Ok(vakue)) { return true };
      case (#Err(error)) { return false };
    };

  };

  private func transfer_tokens_from_canister(_amount : Nat, user : Principal) : async Bool {

    let transferResults = await ICRCLedger.icrc1_transfer({
      from_subaccount = null;
      to = {
        owner = user;
        subaccount = null;
      };
      fee = null;
      amount = _amount;
      memo = null;
      created_at_time = null;
    });

    switch (transferResults) {
      case (#Ok(value)) { return true };
      case (#Err(error)) { return false };
    };

  };

We define two private functions:

  • transfer_funds_from_user : This function helps us to transfer tokens from the user's account on their behalf to the backend canister account. This is useful when the user wants to deposit tokens into our Dapp. The function calls the icrc2_transfer_from method on the ICRCLedger canister specifying the user's account as the source(from) of the token and the backend canister account as the destination(to) of the tokens. If the transfer is successful, the function returns a boolean true, otherwise, it returns false

  • transfer_tokens_from_canister: The function helps us to transfer tokens from the backend canister account to the user's account. This is useful in situations when we are transferring the user's rewards from staking as well as their initial token deposit to the account. The function calls the icrc1_transfer method on the token ICPLedger canister specifying the user's account as the destination(to) account for the tokens. In this case, the source(from) of the tokens by default becomes the backend canister account.

private func calculate_rewards(_amount : Nat, _startTime : Int) : async Nat {
    let timeElapsed = Time.now() - _startTime;
    let realElapsedTime = Float.div(Float.fromInt(timeElapsed), Float.fromInt(totalTimeInAYear));
    let rewards = Float.mul(Float.mul(Float.fromInt(_amount), 0.08), realElapsedTime);
    return Int.abs(Float.toInt(rewards));
  };

  public shared ({ caller }) func claim_rewards() : async Result.Result<(), Text> {
    switch (usersHashMap.get(caller)) {
      case (null) {
        return #err("no user found");
      };
      case (?user) {
        //get their rewards
        let rewards = await calculate_rewards(user.amount, user.startTime);
        if (rewards < 30000) {
          return #err("rewards too low, cant be claimed, keep staking");
        } else {
          let transfer = await transfer_tokens_from_canister(rewards-10000, caller);
          if (transfer) {
            usersHashMap.put(caller, { user with startTime = Time.now() });
            return #ok();
          } else {
            return #err("reward transfer failed");
          };
        };
      };
    };
  };

We define the two functions :

  • calculate_rewards : This accepts two parameters _amount and the _startTime and calculates the rewards basing on the formula amount * APY *(totalElapsedTime/TotalTimeInAYear). We convert everything to floating numbers for conviniency. The function then returns the results as a Nat type to match the token type

  • claim_rewards: The function allows the user to claim any rewards that they have accumulated while staking in our Dapp. When the user calls this function, it first checks to see if the user exists, and if so, it calculates the rewards using the amount that the user deposited as well as the time they deposited those tokens. The rewards are then transferred to the user's account only if they exceed a certain amount 30000. So, every time the user claims their rewards, there will be a transaction fee of 10000 that needs to be paid. In this case we limit the user to claim rewards only when they reach a certain amount so as we can deduct the transaction fees from the reward amount. Finally, we transfer the rewards to the user from the backend canister account after deducting the transaction fee and return an appropriate message depending on the outcome of the whole operation

public shared ({ caller }) func stake_tokens(_amount : Nat) : async Result.Result<(), Text> {
    let results = await transfer_funds_from_user(caller, _amount);
    if (results) {
      usersHashMap.put(caller,{amount = _amount;startTime = Time.now();},);
      return #ok();
    } else {
      return #err("unable to stake tokens");
    };
  };

   public shared ({ caller }) func unstake_tokens() : async Result.Result<(), Text> {
    switch (usersHashMap.get(caller)) {
      case (?data) {
        let rewards = await calculate_rewards(data.amount, data.startTime);
        let transferResults = await transfer_tokens_from_canister(rewards, caller);
        if (transferResults) {
          let transferPrincipal = await transfer_tokens_from_canister(data.amount -20000, caller);
          if (transferPrincipal) {
            usersHashMap.delete(caller);
            return #ok();
          } else {
            return #err("cant transfer principal");
          };
        } else {
          return #err("cant transfer rewards");
        };
      };
      case (null) { return #err("no user found") };
    };
  };

 public func get_user_stake_info(user:Principal):async Result.Result<{amount:Nat;rewards:Nat;startTime:Int;},Text>{
        switch(usersHashMap.get(user)){
          case(?user){
            let rewards = await calculate_rewards(user.amount,user.startTime);
            return #ok({
              amount=user.amount;
              rewards= rewards;
              startTime=user.startTime;
            });
          };
          case(null){return #err("no user found")};
        }
  };

Next, we define two more functions:

  • stake_tokens: This allows the user to stake their tokens our Dapp. The function tracks the user that has called it. It accepts a parameter _amount which is the amount of tokens the user wishes to stake in our Dapp. The function will first transfer the funds from the user's account to the backend account using the transfer_funds_from_user helper function. If the transfer is successful, we save the details of the user in our store, and if the transfer of the funds fails, we return a message telling the use of the issue. For the token transfer to succeed, the user must have approved the backend canister to transfer the funds on their behalf.
  • unstake_tokens. The function allows the user to unstake their tokens from our Dapp. When the user requests to unstake their tokens, we first calculate all the rewards they have accumulated from the time they started staking until now, we transfer the rewards as well as their initial deposit back to their account. We charge the transaction fee to transfer their rewards as well as their initial amount. We then delete the user from the store(database). We return the appropriate message whether the action was successful or not.

  • get_user_stake_info : This function helps us to retrieve information about a specific usrer. It returns their staked amount, the rewards they have as of now, and the time they started staking

The whole code for the backend should look like this

import Bool "mo:base/Bool";
import Int "mo:base/Int";
import Nat "mo:base/Nat";
import HashMap "mo:base/HashMap";
import Principal "mo:base/Principal";
import Result "mo:base/Result";
import Time "mo:base/Time";
import Float "mo:base/Float";
import ICRCLedger "canister:icrc1_ledger_canister";

actor class StakingDapp() = this {

  public type Stake = {
    amount : Nat;
    startTime : Int;
  };

  let totalTimeInAYear = 31536000000000000;

  let usersHashMap = HashMap.HashMap<Principal, Stake>(0, Principal.equal, Principal.hash);

  public shared ({ caller }) func stake_tokens(_amount : Nat) : async Result.Result<(), Text> {
    let results = await transfer_funds_from_user(caller, _amount);
    if (results) {
      usersHashMap.put(caller, { amount = _amount; startTime = Time.now() });
      return #ok();
    } else {
      return #err("unable to stake tokens");
    };
  };

  public shared ({ caller }) func claim_rewards() : async Result.Result<(), Text> {
    switch (usersHashMap.get(caller)) {
      case (null) {
        return #err("no user found");
      };
      case (?user) {
        let rewards = await calculate_rewards(user.amount, user.startTime);
        if (rewards < 30000) {
          return #err("rewards too low, cant be claimed, keep staking");
        } else {
          let transfer = await transfer_tokens_from_canister(rewards-10000, caller);
          if (transfer) {
            usersHashMap.put(caller, { user with startTime = Time.now() });
            return #ok();
          } else {
            return #err("reward transfer failed");
          };
        };
      };
    };
  };

  public shared ({ caller }) func unstake_tokens() : async Result.Result<(), Text> {
    switch (usersHashMap.get(caller)) {
      case (?data) {
        let rewards = await calculate_rewards(data.amount, data.startTime);
        let transferResults = await transfer_tokens_from_canister(rewards, caller);
        if (transferResults) {
          let transferPrincipal = await transfer_tokens_from_canister(data.amount -20000, caller);
          if (transferPrincipal) {
            usersHashMap.delete(caller);
            return #ok();
          } else {
            return #err("cant transfer principal");
          };
        } else {
          return #err("cant transfer rewards");
        };
      };
      case (null) { return #err("no user found") };
    };
  };

  public func get_user_stake_info(user : Principal) : async Result.Result<{ amount : Nat; rewards : Nat; startTime : Int }, Text> {
    switch (usersHashMap.get(user)) {
      case (?user) {
        let rewards = await calculate_rewards(user.amount, user.startTime);
        return #ok({
          amount = user.amount;
          rewards = rewards;
          startTime = user.startTime;
        });
      };
      case (null) { return #err("no user found") };
    };
  };

  private func transfer_tokens_from_canister(_amount : Nat, user : Principal) : async Bool {
    let transferResults = await ICRCLedger.icrc1_transfer({
      from_subaccount = null;
      to = {
        owner = user;
        subaccount = null;
      };
      fee = null;
      amount = _amount;
      memo = null;
      created_at_time = null;
    });

    switch (transferResults) {
      case (#Ok(value)) { return true };
      case (#Err(error)) { return false };
    };

  };

  private func calculate_rewards(_amount : Nat, _startTime : Int) : async Nat {
    let timeElapsed = Time.now() - _startTime;
    let realElapsedTime = Float.div(Float.fromInt(timeElapsed), Float.fromInt(totalTimeInAYear));
    let rewards = Float.mul(Float.mul(Float.fromInt(_amount), 0.08), realElapsedTime);
    return Int.abs(Float.toInt(rewards));
  };

  private func transfer_funds_from_user(user : Principal, _amount : Nat) : async Bool {

    let transferResults = await ICRCLedger.icrc2_transfer_from({
      spender_subaccount = null;
      from = {
        owner = user;
        subaccount = null;
      };
      to = {
        owner = Principal.fromActor(this);
        subaccount = null;
      };
      amount = _amount;
      fee = null;
      memo = null;
      created_at_time = null;
    });

    switch (transferResults) {
      case (#Ok(vakue)) { return true };
      case (#Err(error)) { return false };
    };
  };
};

Deploying the backend

dfx deploy backend

Running this command in your terminal will deploy the backend canister and if the deployment is succesful, you should get a link to interact with the backend canister from the browser. The code for the backend can be found here

Frontend Development

In this section, we are going to look at how to develop the frontend code for our application.

When we ran our templated for the first time, we also installed the necessary packages that we need for our frontend. Some of the packages include;

  • react-router-dom This will help us to handle routes and routing to different pages of our Dapp.
  • tailwindcss A CSS library that allows us to write less CSS code for our project.
  • @dfinity/agent,@dfinity/principal These will help us to interact with the ICP canisters and network.
  • @tanstack/react-query : A state management library to allow us handle and manage states across different pages.

Configuring the main.jsx file

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.scss';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
         refetchOnWindowFocus: false,  
      },
    },
  });

ReactDOM.createRoot(document.getElementById('root') ).render(
    <QueryClientProvider client={queryClient}>
         <BrowserRouter>
        <App />
         </BrowserRouter>
    </QueryClientProvider>
);

In the above code, we perform the necessary configurations for the our routing and state management package

App.jsx

import { useEffect, useState } from 'react';
import './App.css';
import Login from './components/Login';
import {Route,Routes} from "react-router-dom"
import Dashboard from './components/Dashboard';
import SharedLayout from './components/SharedLayout';
function App() {

  return (
    <>
      <Routes>
        <Route path="/" element={<SharedLayout />}>

          <Route index element={<Login/>}/>
          <Route path="/dashboard" element={<Dashboard />} />
        </Route>
      </Routes>
    </>
  );
}

export default App;

Replace the code in the App.jsx file with the above code. We import all the necessary files, and finally we define the routes for our project

FIle Structure.

We will create a new folder inside the src folder called components and that's where all our other files will be put. These files include the Login.jsx, Dashboard.jsx,useData.jsx and SharedLayout.jsx. Your file structure should look like this.

file-structure

Login.jsx

import React, { useState } from "react";
import { AuthClient } from '@dfinity/auth-client';
import {
  canisterId as backendCanisterID,
  createActor,
} from '../declarations/backend';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import {canisterId as LedgerID, createActor as createLedgerActor} from "../declarations/icrc1_ledger_canister"

import UseData from "./useData";
const StakingApp = () => {
  const navigate = useNavigate();
const queryClient = useQueryClient();

const IdentityHost =
process.env.DFX_NETWORK === 'ic'
  ? 'https://identity.ic0.app/#authorize'
  : `http://localhost:4943?canisterId=${process.env.CANISTER_ID_INTERNET_IDENTITY}#authorize`;

const HOST =
process.env.DFX_NETWORK === 'ic'
  ? 'https://identity.ic0.app/#authorize'
  : 'http://localhost:4943';

const days = BigInt(1);
const hours = BigInt(24);
const nanoseconds = BigInt(3600000000000);

const defaultOptions = {
createOptions: {
  idleOptions: {
    // Set to true if you do not want idle functionality
    disableIdle: true,
  },
},
loginOptions: {
  identityProvider: IdentityHost,
  // Maximum authorization expiration is 8 days
  maxTimeToLive: days * hours * nanoseconds,
},
};


const handleLogin = async () => {
try {
  const authClient = await AuthClient.create(defaultOptions.createOptions);

  if (await authClient.isAuthenticated()) {
    handleAuthenticated(authClient);
  } else {
    await authClient.login({
      identityProvider: IdentityHost,
      onSuccess: () => {
        handleAuthenticated(authClient);
      },
    });
  }
} catch (error) {
alert(error)}
};



async function handleAuthenticated(authClient) {
if (!(await authClient?.isAuthenticated())) {
  navigate('/');
  return;
}
const identity = authClient.getIdentity();
const principal = identity.getPrincipal();

const actor = createActor(backendCanisterID, {
  agentOptions: {
    identity,
  },
});

const legActor = createLedgerActor(LedgerID,{
  agentOptions:{
    identity
  },
})

let balance = await legActor.icrc1_balance_of({
  owner:principal,
  subaccount:[]
})
const stakeData = await actor.get_user_stake_info(principal)


await Promise.all([
  queryClient.setQueryData(['tokenLedger'],legActor),
  queryClient.setQueryData(['userBalance'],Number(balance)/1e8),
  queryClient.setQueryData(['stakeData'], stakeData?.ok?stakeData.ok :{}),
  queryClient.setQueryData(['principal'], principal?.toString()),
  queryClient.setQueryData(['backendActor'], actor),
  queryClient.setQueryData(
    ['isAuthenticated'],
    await authClient?.isAuthenticated(),
  ),
])
navigate('/dashboard');
}


  return (
    <div
      className=" flex justify-center items-center h-full flex-col mt-16 w-full">
      <div className="py-16 px-4">
        <div className="max-w-7xl mx-auto">
          <h2 className="text-4xl font-bold text-gray-900">Welcome to the Staking App</h2>
          <p className="text-gray-600">Earn passive income by staking your STT tokens.</p>
          <button className="bg-purple-400 text-white py-2 px-4 rounded mt-8" onClick={handleLogin}>
            Login
          </button>
        </div>
      </div>

    </div>
  );
};

export default StakingApp;

In the above code, we configure our Login page to display a simple text Welcome to the Staking Dapp as well as a login button. When the user clicks on the login button, we call the handleLogin which prompts the user to login using their internet identity anchor. On a successful log in, we perform the following tasks,

  • create a new backend actor using the logged in user's identity -create a new token actor using the logged in user's identity
  • fetch the token balance of the logged in user from the token ledger canister
  • fetch information about the logged in user from the backend canister
  • we update the different variables to hold the respective data using tanstack as our state management tool.

    Finally, we redirect the user to the dashboard page

login

SharedLayout.jsx

import React, { Suspense } from 'react'
import { Outlet } from 'react-router-dom'

const SharedLayout = () => {
  return (
    <>
      <Suspense fallback={<div>loading ....</div>}>
        <Outlet />
      </Suspense>
    </>
  )
}

export default SharedLayout

The above code helps us to manage our routes effectively bby implementing the Outlet method from react-router-dom library

useData.jsx

import React from "react"
import { useQuery, useQueryClient, } from '@tanstack/react-query'
import { Principal } from "@dfinity/principal";


const UseData = () => {

    const queryClient = useQueryClient();

    const { data: isAuthenticated } = useQuery({
        queryKey: ['isAuthenticated'],
    });

    const { data: principal } = useQuery({
        queryKey: ['principal'],
    });
    const { data: backendActor } = useQuery({
        queryKey: ['backendActor'],
    });

    const { data: tokenLedger } = useQuery({
        queryKey: ['tokenLedger'],
    });


    const bal = useQuery({
        queryKey: ['userBalance'],
        queryFn: () => loadUserBalance(),
    });


    const loadUserBalance = async () => {
        try {

            if (!isAuthenticated || !principal || !tokenLedger) {
                alert("login first")
                return
            }

            let tokenBal = await tokenLedger?.icrc1_balance_of({
                owner: Principal.fromText(principal),
                subaccount: []
            })
            return Number(tokenBal) / 1e8
        } catch (error) {
            alert(error)
        }
    }


    const stake = useQuery({
        queryKey: ['stakeData'],
        queryFn: () => loadUserStakeInfo(),
    });

    const loadUserStakeInfo = async () => {
        try {
            if (!isAuthenticated || !principal || !backendActor) { return }
            let userStake = await backendActor.get_user_stake_info(Principal.fromText(principal))
            return userStake?.ok ? userStake.ok : {}
        } catch (error) {
            alert(error)
        }
    }

    const invalidateUserBalance = async () => {
        await queryClient.invalidateQueries(['userBalance']);
    }

    const invalidateUserStake = async () => {
        await queryClient.invalidateQueries(['stakeData'])
    }

    return {
        invalidateUserBalance,
        invalidateUserStake

    }
}

export default UseData

In the above code, we handle the re-fetching of the user's token balance from the token ledger canister as well as their stake information from the backend canister using the respective methods from tanstack library. The re-fetching of such data is useful when we need to display updated information for example after the user has staked their tokens in our Dapp.

Dashboard.jsx


import React, { useEffect, useState } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { canisterId as BackendID } from "../declarations/backend"
import { Principal } from "@dfinity/principal"
import { useNavigate } from "react-router-dom"
import UseData from "./useData"
const Dashboard = () => {

    const { invalidateUserBalance,invalidateUserStake} = UseData()

    const [stakeAmount, setStakeAmount] = useState(0)

    const navigate = useNavigate()

    const { data: isAuthenticated } = useQuery({
        queryKey: ['isAuthenticated'],
    });
    const { data: principal } = useQuery({
        queryKey: ['principal'],
    });
    const { data: userBalance } = useQuery({
        queryKey: ['userBalance'],
    });

    const { data: tokenLedger } = useQuery({
        queryKey: ['tokenLedger'],
    });

    const { data: backendActor } = useQuery({
        queryKey: ['backendActor'],
    });

    const { data: stakeData } = useQuery({
        queryKey: ['stakeData'],
    });

    const handleChange = (e) => {
        setStakeAmount(e.target.value)

    }

    const { mutateAsync: HandleStakeRequest } = useMutation({
        mutationFn: () => handleStake(),
        onSuccess: async () => {
            await invalidateUserBalance()          
              await invalidateUserStake()      
              },
    });


    const handleStake = async () => {
        try {
            if (!backendActor || !tokenLedger || !isAuthenticated) {
                alert("you need to login first")
                return
            } else if (stakeAmount >= userBalance) {
                alert("you dont have enought funds")
                return
            }

            let formattedStake = stakeAmount * 1e8;
            const approveResult = await tokenLedger.icrc2_approve({
                fee: [],
                memo: [],
                from_subaccount: [],
                created_at_time: [],
                amount: formattedStake + 10000,//add the 10000 which is the token transfer fee
                expected_allowance: [],
                expires_at: [],
                spender: {
                    owner: Principal.fromText(BackendID),
                    subaccount: []
                }
            })

            let stakeResults = await backendActor.stake_tokens(formattedStake);
            console.log("staking res :",stakeResults);
            stakeResults?.ok === null ? alert("staking successful") : alert(stakeResults.err)
            return stakeResults
        } catch (error) {
            console.log("error in stating :",error);
            alert(error)
        }
    }

    const { mutateAsync: HandleClaimRequest} = useMutation({
        mutationFn: () => handleClaim(),
        onSuccess: async () => {
            await invalidateUserBalance()          
              await invalidateUserStake()      
              },
    });

    const handleClaim = async () => {
        if (!backendActor || !tokenLedger || !isAuthenticated) {
            alert("you need to login first")
            return
        }
        try {
            let claimResults = await backendActor.claim_rewards();
            claimResults?.ok === null ? alert("claim successful") : alert(claimResults.err)
            return claimResults
        } catch (error) {
            alert(error)
        }
    }

    const { mutateAsync: HandleUnstakeRequest } = useMutation({
        mutationFn: () => handleUnstake(),
        onSuccess: async () => {
            await invalidateUserBalance()          
            await invalidateUserStake() 
        },
    });


    const handleUnstake = async () => {
        if (!backendActor || !tokenLedger || !isAuthenticated) {
            alert("you need to login first")
            return
        }
        let res = await backendActor.unstake_tokens()
        res?.ok === null ? alert("unstaking successdul") : alert(res.err)
        if (res.err) {
            alert(res.err)
        }
        return res;
    }

    return (
        <>
        {
            isAuthenticated?
            <div className="flex flex-col w-full min-h-full justify-center items-center">
            <h1 className="border-b-2 p-2 mb-8">Dashboard</h1>
            <div className="flex flex-col gap-2">
                <span>Principal ID</span>
                <span>{principal && principal}</span>
                <span>Balance : {userBalance && userBalance} STT</span>
            </div>
            {
                Object.keys(stakeData).length > 0 ?
                    // stakeData?.amount ?
                    <div className="flex flex-col justify-center items-center p-1 border rounded-md mt-6">
                        <span> Staked :{Number(stakeData.amount) / 1e8} STT </span>
                        <span>Rewards : {(Number(stakeData.rewards) / 1e8).toFixed(4)} STT</span>
                        <div className=" flex gap-3 p-2 mt-4">
                            <button onClick={HandleClaimRequest} className="text-sm">Claim Rewards</button>
                            <button onClick={HandleUnstakeRequest} className="text-sm">UnStake Tokens</button>
                        </div>
                    </div>
                    :
                    <div className="flex flex-col gap-4 border p-2 mt-6 ">
                        <input className="h-12" type="number" placeholder="enter stake amount" value={stakeAmount} onChange={handleChange} />
                        <button onClick={HandleStakeRequest}>Stake</button>
                    </div>
            }
        </div>
        :navigate("/")
        }
</>
    )
}

export default Dashboard

In the above code, if it's the user's first time to login, we configure the Dashboard page to display an input box and a button to allow the user stake their tokens, otherwise we display their stake information from their backend canister. We define some functions that help the user to perform the different tasks of staking, unstaking and claiming their rewards. We utilize the tanstack library to help us re-fetch the respective data after every operation.

If the user has logged in for the first time, the dashboard should look like this

And if the user has staked their tokens, the dashboard should look like this.

The code for the frontend can be found here

Frontend Deployment

dfx generate
dfx deploy

This will deploy all the canisters in our project which include the backend,icrc-ledger-canister and the frontend canister. after the deployment is successful, you should get urls for all the deployed canisters

Clicking on the frontend canister url should open it in the broswer and allow you to interact with the application.

Trasfering tokens to the user.

In this section, we will look at how to donate some STT tokens to the logged in user to allow them to use and stake in the Dapp. Here are steps you need to follow in order to transfer some STT tokens to the user.

  • Logged in to the Dapp and copy the principal address

In your terminal, run the folowing command

dfx identity use default
dfx canister call icrc1_ledger_canister icrc1_transfer "(record { to = record { owner = principal \"PRINCIPAL-ADDRESS-FROM-LOGGED-IN-USER\";};  amount = 1110000_000;})"

Replace the PRINCIPAL-ADDRESS-FROM-LOGGED-IN-USER with the principal address thhat you copied from the dashboard. Now the logged in user has some tokens that they can use to stake in the Dapp.

Next Steps

This is a simplified version of the staking Dapp, it only covers the concepts behind how staking works and how to connect the frontend to the backend. You can further improve this project by adding in more login methods like plug,stoic wallets, using ICP or ckBTC instead of the custom token that we created among other improvements.

About Author

Samuel Atwebembeire is a web3 developer and Technical writer who loves trying out new technologies and pasionate about teaching others on how to use these technologies. Connect on Twitter