Build a Frontend
Functions
Core Functions
Before we start building our frontend, we need to develop some functions that will handle the core logic of our application independently of any frontend frameworks. Once these functions are implemented, then we can integrate them with our chosen frontend framework to create a fully functional application.
These functions will receive the account state as their input, which is defined as follows:
import type { Signer } from 'ethers'import type { Web3Provider } from '@ethersproject/providers'interface Account { address: string provider: Web3Provider signer: Signer}
Additionally, these functions also require a Token contract instance. This can be created using the following code:
import { Contract } from 'ethers'const Token = new Contract('TOKEN_ADDRESS', ['TOKEN_ABI'])
Function: getBalance
const getBalance = async ({ address, signer }: Account): Promise<BigNumber> => { return await Token.connect(signer).balanceOf(address)}
To retrieve the Token balance of a specific address, you can create a function called getBalance
. This function will call the balanceOf
method of the Token contract using the provided signer to fetch the balance of the given address. The function then returns this balance as a BigNumber
object. This function can be useful for displaying the current balance of a user's Token holdings in the frontend.
Function: getProof
import { RelicClient } from '@relicprotocol/client'const getProof = async ({ address, provider }: Account) => { const client = await RelicClient.fromProvider(provider) return await client.birthCertificateProver.getProofData({ account: address, })}
To retrieve the proof data required for verifying a fact on the Relic Protocol, you can implement a function called getProof
. This function uses the RelicClient
with the provided provider. It then calls the getProofData
method of the prover with the given address to retrieve the proof data. This function is useful for checking which proof data is included in a transaction for Verification process.
Function: setFact
import { RelicClient } from '@relicprotocol/client'const setFact = async ({ address, provider, signer }: Account) => { const client = await RelicClient.fromProvider(provider) const tx = await client.birthCertificateProver.prove({ account: address }) const res = await signer.sendTransaction(tx) return await res.wait()}
The setFact
function is used to perform the Verification process on the Relic Protocol. It uses the prover’s prove
method to generate the Verification transaction. Once the transaction is generated, it is signed with the provided signer
and sent to the network.
Function: mint
import { RelicClient } from '@relicprotocol/client'const mint = async ({ address, signer }: Account) => { const res = await Token.connect(signer).mint(address) return await res.wait()}
The mint
function demonstrates the Access process on the Relic Protocol. It utilizes the Token
contract's mint
function to mint new tokens for the specified address
. The function calls the mint
function with the provided signer
and address
, and then waits for the transaction to be confirmed before returning the result of the wait call.
Adapters
In this tutorial, we are using the Web3-Onboard library to simplify the integration between the web application and the wallet. If you are using a different library for wallet integration, you may need to modify the adapters.
Function: toAccount
import type { WalletState } from '@web3-onboard/core'import { providers } from 'ethers'const toAccount = (wallet: WalletState): Account => { const address = wallet.accounts[0].address const provider = new providers.Web3Provider(wallet.provider) const signer = provider.getSigner() return { address, provider, signer }}
To convert the WalletState
object provided by Web3-Onboard to the Account
object required by the core functions, we need to use the toAccount
function.
Reactivity
Let's define the states for the web application and make the core functions reactive.
In this tutorial, we are using Vue as the frontend framework. However, if you are using a different frontend framework like React, you can still use the functions we defined earlier.
State
import type { ProofData } from '@relicprotocol/client'import type { TransactionReceipt } from '@ethersproject/abstract-provider'import { ref } from 'vue'import { useOnboard } from '@web3-onboard/vue'const { connectedWallet, connectWallet } = useOnboard()const [proof, factTxReceipt, mintTxReceipt, balance] = [ ref<ProofData>(), ref<TransactionReceipt>(), ref<TransactionReceipt>(), ref<BigNumber>(),]const state = { connectedWallet, // Ref<WalletState | null> proof, factTxReceipt, mintTxReceipt, balance,}
To make the web application reactive, we need to define the application state. Here are some key state variables we can define:
connectedWallet
: This is the wallet's state provided by web3-onboard library.proof
: This variable can be used to save the return value of thegetProof
function.factTxReceipt
: This variable can be used to save the return value of thesetFact
function.mintTxReceipt
: This variable can be used to save the return value of themint
function.balance
: This variable can be used to save the return value of thegetBalance
function.
Reactify
import type { Ref } from 'vue'const reactify = <T>({ wallet, result, }: { wallet: Ref<WalletState | null> result?: Ref<T> }) => (f: (w: WalletState) => Promise<T>) => async () => { const _wallet = wallet.value if (_wallet !== null) { const value = await f(_wallet) if (result) { result.value = value } } }
The reactify function is designed to simplify the process of integrating the core functions into your Vue.js application with reactivity. This function acts as a factory that generates a reactive function by wrapping the core functions. It takes the wallet's state as well as a state variable for storing the return value of the core function.
View
The following code creates Vue components with reactivity by utilizing the previously defined functions:
<script setup lang="ts"> import type { Ref } from 'vue' import type { BigNumber, Signer } from 'ethers' import type { Web3Provider } from '@ethersproject/providers' import type { WalletState } from '@web3-onboard/core' import type { TransactionReceipt } from '@ethersproject/abstract-provider' import type { ProofData } from '@relicprotocol/client' import { ref } from 'vue' import { Contract, providers } from 'ethers' import { useOnboard } from '@web3-onboard/vue' import { RelicClient } from '@relicprotocol/client' interface Account { address: string provider: Web3Provider signer: Signer } const Token = new Contract('TOKEN_ADDRESS', ['TOKEN_ABI']) const getBalance = async ({ address, signer, }: Account): Promise<BigNumber> => { return await Token.connect(signer).balanceOf(address) } const getProof = async ({ address, provider }: Account) => { const client = await RelicClient.fromProvider(provider) return await client.birthCertificateProver.getProofData({ account: address, }) } const setFact = async ({ address, provider, signer }: Account) => { const client = await RelicClient.fromProvider(provider) const tx = await client.birthCertificateProver.prove({ account: address }) const res = await signer.sendTransaction(tx) return await res.wait() } const mint = async ({ address, signer }: Account) => { const res = await Token.connect(signer).mint(address) return await res.wait() } const toAccount = (wallet: WalletState): Account => { const address = wallet.accounts[0].address const provider = new providers.Web3Provider(wallet.provider) const signer = provider.getSigner() return { address, provider, signer } } const reactify = <T>({ wallet, result, }: { wallet: Ref<WalletState | null> result?: Ref<T> }) => (f: (w: Account) => Promise<T>) => async () => { const _wallet = wallet.value if (_wallet !== null) { const value = await f(toAccount(_wallet)) if (result) { result.value = value } } } const { connectWallet, connectedWallet } = useOnboard() const [proof, factTxReceipt, mintTxReceipt, balance] = [ ref<ProofData>(), ref<TransactionReceipt>(), ref<TransactionReceipt>(), ref<BigNumber>(), ] const state = { connectedWallet, proof, factTxReceipt, mintTxReceipt, balance, }</script><template> <main> <div class="app"> <h1>dApp</h1> <template v-if="connectedWallet !== null"> <div class="entry"> <h2>Token Balance</h2> <pre>{{ state.balance.value }}</pre> <button @click=" reactify({ wallet: state.connectedWallet, result: state.balance, })(getBalance)() " > Get Balance </button> </div> <div class="entry"> <h2>Proof</h2> <pre>{{ state.proof.value }}</pre> <button @click=" reactify({ wallet: state.connectedWallet, result: state.proof })( getProof )() " > Get Proof </button> </div> <div class="entry"> <h2>Verify</h2> <pre>{{ state.factTxReceipt.value }}</pre> <button @click=" reactify({ wallet: state.connectedWallet, result: state.factTxReceipt, })(setFact)() " > Set Fact </button> </div> <div class="entry"> <h2>Access</h2> <pre>{{ state.mintTxReceipt.value }}</pre> <button @click=" reactify({ wallet: state.connectedWallet, result: state.mintTxReceipt, })(mint)() " > Mint Token </button> </div> </template> <template v-else> <div> <button @click="connectWallet()">Connect Wallet</button> </div> </template> </div> </main></template>
Creating a new Vue Project
Initialization
To see the previous component, let's create a new Vue project by running the following command and following the instructions:
npm init vue@latest
Vue.js - The Progressive JavaScript Framework
✔ Project name: … frontend
✔ Add TypeScript? … No / Yes
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes
✔ Add Pinia for state management? … No / Yes
✔ Add Vitest for Unit Testing? … No / Yes
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … No / Yes
✔ Add Prettier for code formatting? … No / Yes
Scaffolding project in /private/tmp/relic-tutorial/frontend...
Done.
Then, you need to install the dependencies required for the web application. To do so, navigate into the frontend directory and run the following command:
cd frontendnpm install @relicprotocol/client @web3-onboard/injected-wallets @web3-onboard/vuenpm install --save-dev vite-plugin-node-polyfillsnpm run format
Additionally, you can remove any unused code in your project by running the following command:
rm -rf src/assets src/components src/views/AboutView.vue
Configuration
To use ethers in the browser, you may need to include polyfills for its dependencies. The vite-plugin-node-polyfills
can simplify this process by using the following configuration in your vite.config.js
file:
// File: frontend/vite.config.tsimport { fileURLToPath, URL } from 'node:url'import { defineConfig } from 'vite'import { nodePolyfills } from 'vite-plugin-node-polyfills'import vue from '@vitejs/plugin-vue'// https://vitejs.dev/config/export default defineConfig({ plugins: [vue(), nodePolyfills()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), }, },})
Since we will be using only one view in this project, we can simplify the router configuration by using the following code:
// File: frontend/src/router/index.tsimport { createRouter, createWebHistory } from 'vue-router'import HomeView from '../views/HomeView.vue'const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'home', component: HomeView, }, ],})export default router
To use the web3-onboard library, you need to initialize it. Use the following code to initialize the library. Note that the rpcUrl
can be changed based on your machine's environment:
// File: frontend/src/App.vue<script setup lang="ts"> import { RouterView } from 'vue-router' import { init } from '@web3-onboard/vue' import injectedWallets from '@web3-onboard/injected-wallets' init({ wallets: [injectedWallets()], chains: [ { id: '0x1', token: 'ETH', label: 'Localhost', rpcUrl: 'http://127.0.0.1:8545/', }, ], accountCenter: { desktop: { enabled: false, }, mobile: { enabled: false, }, }, })</script><template> <RouterView /></template>
Modifying Contracts Deployment Script
const Token = new Contract("TOKEN_ADDRESS", ["TOKEN_ABI"])
It can be a tedious task to manually update the contract address in the code every time it is redeployed. To simplify this process, you can modify the deployment script to dynamically update the address and ABI whenever the contract is redeployed.
// File: scripts/deploy.ts// https://github.com/NomicFoundation/hardhat-boilerplate/blob/master/scripts/deploy.js// ...import path from 'path'import fs from 'fs'async function main() { // ... const contractsDir = path.join( __dirname, '..', 'frontend', 'src', 'contracts' ) if (!fs.existsSync(contractsDir)) { fs.mkdirSync(contractsDir) } fs.writeFileSync( path.join(contractsDir, 'contract-address.json'), JSON.stringify({ Token: token.address }, undefined, 2) ) const TokenArtifact = artifacts.readArtifactSync('Token') fs.writeFileSync( path.join(contractsDir, 'Token.json'), JSON.stringify(TokenArtifact, null, 2) )}// ...
Now you can create a contract instance using the imported address and ABI from the outside.
import addresses from '@/contracts/contract-address.json'import contract from '@/contracts/Token.json'const Token = new Contract(addresses.Token, contract.abi)
Complete Implementation
The complete code for the component is shown below. You can copy and paste this into the file frontend/src/views/HomeView.vue
.
<script setup lang="ts"> import type { Ref } from 'vue' import type { BigNumber, Signer } from 'ethers' import type { Web3Provider } from '@ethersproject/providers' import type { WalletState } from '@web3-onboard/core' import type { TransactionReceipt } from '@ethersproject/abstract-provider' import type { ProofData } from '@relicprotocol/client' import { ref } from 'vue' import { Contract, providers } from 'ethers' import { useOnboard } from '@web3-onboard/vue' import { RelicClient } from '@relicprotocol/client' import addresses from '@/contracts/contract-address.json' import contract from '@/contracts/Token.json' interface Account { address: string provider: Web3Provider signer: Signer } const Token = new Contract(addresses.Token, contract.abi) const getBalance = async ({ address, signer, }: Account): Promise<BigNumber> => { return await Token.connect(signer).balanceOf(address) } const getProof = async ({ address, provider }: Account) => { const client = await RelicClient.fromProvider(provider) return await client.birthCertificateProver.getProofData({ account: address, }) } const setFact = async ({ address, provider, signer }: Account) => { const client = await RelicClient.fromProvider(provider) const tx = await client.birthCertificateProver.prove({ account: address }) const res = await signer.sendTransaction(tx) return await res.wait() } const mint = async ({ address, signer }: Account) => { const res = await Token.connect(signer).mint(address) return await res.wait() } const toAccount = (wallet: WalletState): Account => { const address = wallet.accounts[0].address const provider = new providers.Web3Provider(wallet.provider) const signer = provider.getSigner() return { address, provider, signer } } const reactify = <T>({ wallet, result, }: { wallet: Ref<WalletState | null> result?: Ref<T> }) => (f: (w: Account) => Promise<T>) => async () => { const _wallet = wallet.value if (_wallet !== null) { const value = await f(toAccount(_wallet)) if (result) { result.value = value } } } const { connectWallet, connectedWallet } = useOnboard() const [proof, factTxReceipt, mintTxReceipt, balance] = [ ref<ProofData>(), ref<TransactionReceipt>(), ref<TransactionReceipt>(), ref<BigNumber>(), ] const state = { connectedWallet, proof, factTxReceipt, mintTxReceipt, balance, }</script><template> <main> <div class="app"> <h1>dApp</h1> <template v-if="connectedWallet !== null"> <div class="entry"> <h2>Token Balance</h2> <pre>{{ state.balance.value }}</pre> <button @click=" reactify({ wallet: state.connectedWallet, result: state.balance, })(getBalance)() " > Get Balance </button> </div> <div class="entry"> <h2>Proof</h2> <pre>{{ state.proof.value }}</pre> <button @click=" reactify({ wallet: state.connectedWallet, result: state.proof })( getProof )() " > Get Proof </button> </div> <div class="entry"> <h2>Verify</h2> <pre>{{ state.factTxReceipt.value }}</pre> <button @click=" reactify({ wallet: state.connectedWallet, result: state.factTxReceipt, })(setFact)() " > Set Fact </button> </div> <div class="entry"> <h2>Access</h2> <pre>{{ state.mintTxReceipt.value }}</pre> <button @click=" reactify({ wallet: state.connectedWallet, result: state.mintTxReceipt, })(mint)() " > Mint Token </button> </div> </template> <template v-else> <div> <button @click="connectWallet()">Connect Wallet</button> </div> </template> </div> </main></template>
Preview Web Application
Before previewing the application, you need to deploy the contracts. Let's run the Hardhat network node in the terminal using the following command, in project’s root directory:
npx hardhat node
Afterward, in a new terminal, use the following command to deploy the contract on the Hardhat network:
npx hardhat run --network localhost scripts/deploy.ts
Finally, you can preview the app in the browser by running the following command in the Vue project folder. Check if your wallet is connected to the Hardhat network.
npm run dev