Develop a Full Stack dApp on MELD

Introduction

Decentralized applications, or DApps, have redefined how applications are built, managed, and interacted with in Web3. By leveraging blockchain technology, DApps provide a secure, transparent, and trustless system that enables peer-to-peer interactions without any central authority. At the core of a DApp's architecture are several main components that work in tandem to create a robust, decentralized ecosystem. These components include smart contracts, nodes, frontend user interfaces, and more.

DApp Architecture Diagram

In this tutorial, you'll come face-to-face with each major component by writing a full DApp that mints tokens. We'll also explore additional optional components of DApps that can enhance user experience for your future projects. You can view the complete project in its monorepo on GitHub.

Dapp End Result

Checking Prerequisites

To get started, you should have the following:

  • A wallet on the Kanazawa Testnet funded with tgMELD tokens. You can get tgMELD tokens for testing from our faucet.

  • Node.js version 16 or newer installed

  • VS Code with Juan Blanco's Solidity extension is a recommended IDE

  • Understanding of JavaScript and React

  • Novice familiarity with Solidity. If you are not familiar with writing Solidity, there are many resources out there, including Solidity by Example. A 15-minute skim should suffice for this tutorial

  • A wallet like MetaMask installed

Nodes and JSON-RPC Endpoints

Generally speaking, a JSON-RPC is a remote procedure call (RPC) protocol that utilizes JSON to encode data. For Web3, they refer to the specific JSON-RPCs that DApp developers use to send requests and receive responses from blockchain nodes, making it a crucial element in interactions with smart contracts. They allow frontend user interfaces to seamlessly interact with the smart contracts and provide users with real-time feedback on their actions. They also allow developers to deploy their smart contracts in the first place!

To get a JSON-RPC to communicate with the Kanazawa Testnet you need to run a node. But that can be expensive, complicated, and a hassle. Fortunately, as long as you have access to a node, you can interact with the blockchain. For this tutorial, we will be using the Kanazawa official public node endpoint.

https://testnet-rpc.meld.com/

So now you have a URL. How do you use it? Over HTTPS, JSON-RPC requests are POST requests that include specific methods for reading and writing data, such as eth_call for executing a smart contract function in a read-only manner or eth_sendRawTransaction for submitting signed transactions to the network (calls that change the blockchain state). The entire JSON request structure will always have a structure similar to the following:

{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "eth_getBalance",
    "params": ["0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac", "latest"]
}

This example is getting the balance (in tgMELD on Kanazawa Testnet) of the 0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac account. Let's break down the elements:

  • jsonrpc — the JSON-RPC API version, usually "2.0"

  • id — an integer value that helps identify a response to a request. Can usually just keep it as is.

  • method — the specific method to read/write data from/to the blockchain. You can see many of the RPC methods here.

  • params — an array of the input parameters expected by the specific method

There are also additional elements that can be added to JSON-RPC requests, but those four will be seen the most often.

Now, these JSON-RPC requests are pretty useful, but when writing code, it can be a hassle to create a JSON object over and over again. That's why there exist libraries that help abstract and facilitate the usage of these requests. MELD provides documentation on many libraries, and the one that we will be using in this tutorial is Ethers.js. Just understand that whenever we interact with the blockchain through the Ethers.js package, we're really using JSON-RPC!

Smart Contracts

Smart contracts are self-executing contracts with the terms of the agreement directly written into code. They serve as the decentralized backend of any DApp, automating and enforcing the business logic within the system.

If coming from traditional web development, smart contracts are meant to replace the backend with important caveats: the user must have the native currency (gMELD, ETH, AVAX, etc.) to make state-changing requests, storing information can be expensive, and no information stored is private.

When you deploy a smart contract onto Kanazawa, you upload a series of instructions that can be understood by the EVM, or the Ethereum Virtual Machine. Whenever someone interacts with a smart contract, these transparent, tamper-proof, and immutable instructions are executed by the EVM to change the blockchain's state. Writing the instructions in a smart contract properly is very important since the blockchain's state defines the most crucial information about your DApp, such as who has what amount of money.

Since the instructions are difficult to write and make sense of at a low (assembly) level, we have smart contract languages such as Solidity to make it easier to write them. To help write, debug, test, and compile these smart contract languages, developers in the Ethereum community have created developer environments such as Hardhat and Foundry.

This tutorial will use Hardhat for managing smart contracts.

Create a Hardhat Project

You can initialize a project with Hardhat using the following command:

mkdir meld-example-dapp && cd meld-example-dapp && npx hardhat init

Select a TypeScript Hardhat project, you will be asked if you want to install the sample project's dependencies, which will install Hardhat and the Hardhat Toolbox plugin. Please do so.

Also, please run:

npm install --save-dev hardhat @nomicfoundation/hardhat-ethers ethers@5

Before we start writing the smart contract, let's add a JSON-RPC URL to the config. Replace the relevant code in thehardhat.config.js file with the following code, and replace INSERT_YOUR_PRIVATE_KEY with your funded account's private key.

Rember!

This is for testing purposes, never store your private key in plain text with real funds.

// -- other code --
  solidity: "0.8.20",
    networks: {
        kanazawa: {
            url: "https://testnet-rpc.meld.com/",
            chainId: 222000222,
            accounts: ["INSERT_YOUR_PRIVATE_KEY"],
        },
    },
// -- other code ---

Write Smart Contracts

Recall that we're making a DApp that allows you to mint a token for a price. Let's write a smart contract that reflects this functionality!

Once you've initialized a Hardhat project, you'll be able to write smart contracts in its contracts folder. This folder will have an initial smart contract, likely called Lock.sol, but you should delete it and add a new smart file called MintableERC20.sol.

The standard for tokens is called ERC-20, where ERC stands for "Ethereum Request for Comment". A long time ago, this standard was defined, and now most smart contracts that work with tokens expect tokens to have all of the functionality defined by ERC-20. Fortunately, you don't have to know it from memory since the OpenZeppelin smart contract team provides us with smart contract bases to use.

Install OpenZeppelin smart contracts:

npm install @openzeppelin/contracts

Now, in your MintableERC20.sol, add the following code:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract MintableERC20 is ERC20, Ownable {
    constructor(address initialOwner) ERC20("Mintable ERC 20", "MERC") Ownable(initialOwner) {}
}

When writing smart contracts, you're going to have to compile them eventually. Every developer environment for smart contracts will have this functionality. In Hardhat, you can compile with:

npx hardhat compile

Everything should compile well, which should cause two new folders to pop up: artifacts and cache. These two folders hold information about the compiled smart contracts.

Let's continue by adding functionality. Add the following constants, errors, event, and function to your Solidity file:

    uint256 public constant MAX_TO_MINT = 1000 ether;

    event PurchaseOccurred(address minter, uint256 amount);
    error MustMintOverZero();
    error MintRequestOverMax();
    error FailedToSendEtherToOwner();

    /**Purchases some of the token with native currency. */
    function purchaseMint() payable external {
        // Calculate amount to mint
        uint256 amountToMint = msg.value;

        // Check for no errors
        if(amountToMint == 0) revert MustMintOverZero();
        if(amountToMint + totalSupply() > MAX_TO_MINT) revert MintRequestOverMax();

        // Send to owner
        (bool success, ) = owner().call{value: msg.value}("");
        if(!success) revert FailedToSendEtherToOwner();

        // Mint to user
        _mint(msg.sender, amountToMint);
        emit PurchaseOccurred(msg.sender, amountToMint);
    }
MintableERC20.sol file
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract MintableERC20 is ERC20, Ownable {
    constructor(address initialOwner) ERC20("Mintable ERC 20", "MERC") Ownable(initialOwner) {}

    uint256 public constant MAX_TO_MINT = 1000 ether;

    event PurchaseOccurred(address minter, uint256 amount);
    error MustMintOverZero();
    error MintRequestOverMax();
    error FailedToSendEtherToOwner();

    /**Purchases some of the token with native gas currency. */
    function purchaseMint() external payable {
        // Calculate amount to mint
        uint256 amountToMint = msg.value;

        // Check for no errors
        if (amountToMint == 0) revert MustMintOverZero();
        if (amountToMint + totalSupply() > MAX_TO_MINT)
            revert MintRequestOverMax();

        // Send to owner
        (bool success, ) = owner().call{value: msg.value}("");
        if (!success) revert FailedToSendEtherToOwner();

        // Mint to user
        _mint(msg.sender, amountToMint);
        emit PurchaseOccurred(msg.sender, amountToMint);
    }
}

This function will allow a user to send the native Kanazawa currency (tgMELD) as value because it is a payable function. Let's break down the function section by section.

  1. It will figure out how much of the token to mint based on the value sent

  2. Then it will check to see if the amount minted is 0 or if the total amount minted is over the MAX_TO_MINT, giving a descriptive error in both cases

  3. The contract will then forward the value included with the function call to the owner of the contract (by default, the address that deployed the contract, which will be you)

  4. Finally, tokens will be minted to the user, and an event will be emitted to pick up on later

To make sure that this works, let's use Hardhat again:

npx hardhat compile

You've now written the smart contract for your DApp! If this were a production app, we would write tests for it, but that is out of the scope of this tutorial. Let's deploy it next.

Deploy Smart Contracts

Under the hood, Hardhat is a Node project that uses the Ethers.js library to interact with the blockchain. You can also use Ethers.js in conjunction with Hardhat's tool to create scripts to do things like deploy contracts.

Your Hardhat project should already come with a script in the scripts folder, called deploy.js. Let's replace it with a similar, albeit simpler, script.

import hre from "hardhat";

async function main() {
    const [deployer] = await hre.ethers.getSigners();

    const MintableERC20 = await hre.ethers.getContractFactory("MintableERC20");
    const token = await MintableERC20.deploy(deployer.address);
    await token.waitForDeployment();

    // Get and print the contract address
    const myContractDeployedAddress = await token.getAddress();
    console.log(`Deployed to ${myContractDeployedAddress}`);
}

main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
});

This script uses Hardhat's instance of the Ethers library to get a contract factory of the MintableERC20.sol smart contract that we wrote earlier. It then deploys it and prints the resultant smart contract's address. Very simple to do with Hardhat and the Ethers.js library, but significantly more difficult using just JSON-RPC!

Let's run the contract on Kanazawa (whose JSON-RPC endpoint we defined in the hardhat.config.js script earlier):

npx hardhat run scripts/deploy.ts --network kanazawa

You should see an output that displays the token address. Make sure to save it for use later!

Create a DApp Frontend

Frontends provide an interface for users to interact with blockchain-based applications. React, a popular JavaScript library for building user interfaces, is often used for developing DApp frontends due to its component-based architecture, which promotes reusable code and efficient rendering. ThirdWeb, an Ethers.js based SDK for DApps, further simplifies the process of building DApp frontends by providing a comprehensive set of hooks and components that streamline the integration of Ethereum blockchain functionality.

Note

Typically, a larger project will create separate GitHub repositories for their frontend and smart contracts, but this is a small enough project to create a monorepo.

Create a React Project using Vite

Let's set up a new React project using Vite and install dependencies, which we can create within our Hardhat project's folder without much issue.

npm create vite@latest
// Project name: frontend
// Select a framework: React
// Select a variant: TypeScript + SWC
cd frontend
npm install 
npm install ethers@5 @thirdweb-dev/react @mui/material @mui/system @emotion/react @emotion/styled

If you remember, Ethers.js is a library that assists with JSON-RPC communication. The Thirdweb package is a similar library that uses Ethers.js and formats them into React hooks so that they work better in frontend projects. We've also added two MUI packages for styling and components.

Let's set up the App.tsx file located in the frontend/src directory to add some visual structure:

import "./App.css";

import { Box, Typography } from "@mui/material";

function App() {
    return (
        <>
            <Box>
                <Typography variant="h1">Your First Dapp!</Typography>
            </Box>
        </>
    );
}

export default App;

You can start the React project by running the following command from within the frontend directory:

npm run dev

Your frontend will be available at localhost:5173.

At this point, our frontend project is set up well enough to start working on the functional code!

Providers, Signers, and Wallets

The frontend communicates with the blockchain using JSON-RPC, but we will be using Ethers.js. When using JSON-RPC, Ethers.js likes to abstract degrees of interaction with the blockchain into objects, such as providers, signers, and wallets.

Providers are the bridge between the frontend user interface and the blockchain network, facilitating communication and data exchange. They abstract the complexities of interacting with the blockchain, offering a simple API for the frontend to use. They are responsible for connecting the DApp to a specific blockchain node, allowing it to read data from the blockchain, and essentially contain the JSON-RPC URL.

Signers are a type of provider that contain a secret that can be used to sign transactions with. This allows the frontend to create transactions, sign them, and then send them with eth_sendRawTransaction. There are multiple types of signers, but we're most interested in wallet objects, which securely store and manage users' private keys and digital assets. Wallets such as MetaMask facilitate transaction signing with a universal and user-friendly process. They act as a user's representation within the DApp, ensuring that only authorized transactions are executed. The Ethers.js wallet object represents this interface within our frontend code.

Typically, a frontend using Ethers.js will require you to create a provider, connect to the user's wallet if applicable, and create a wallet object. This process can become unwieldy in larger projects, especially with the number of wallets that exist other than MetaMask.

Example of unwieldy MetaMask handling
// Detect if the browser has MetaMask installed
let provider, signer;
if (typeof window.ethereum !== 'undefined') {
  // Create a provider using MetaMask
  provider = new ethers.BrowserProvider(window.ethereum);

  // Connect to MetaMask
  async function connectToMetaMask() {
    try {
      // Request access to the user's MetaMask account
      const accounts = await window.ethereum.request({
        method: 'eth_requestAccounts',
      });

      // Create a signer (wallet) using the provider
      signer = provider.getSigner(accounts[0]);
    } catch (error) {
      console.error('Error connecting to MetaMask:', error);
    }
  }

  // Call the function to connect to MetaMask
  connectToMetaMask();
} else {
  console.log('MetaMask is not installed');
}

// ... also the code for disconnecting from the site
// ... also the code that handles other wallets

Fortunately, we have installed the ThirdWeb package, which simplifies many of the processes for us. This simultaneously abstracts what Ethers is doing as well, which is why we took a bit of time to explain them here.

Create a Provider

Let's do a bit of setup with the ThirdWeb SDK. First, in your React frontend's App.tsx file, which is located in the frontend/src directory, add a ThirdwebSDKProvider component. You'll need to sign up (for free) and get yourself a client id. We will be using the Thirdweb SDK throughout the project to simplify our interactions with the Kanazawa Testnet.

import "./App.css";

import { Box, Typography } from "@mui/material";
import { ConnectWallet, ThirdwebProvider } from "@thirdweb-dev/react";

import { Kanazawa, Meld } from "@thirdweb-dev/chains";

function App() {
    return (
        <ThirdwebProvider
            activeChain={Kanazawa}
            supportedChains={[Meld, Kanazawa]}
            clientId="your-client-id"
        >
        </ThirdwebProvider>
    );
}

export default App;

Connect to a Wallet

Now in your App.tsx file, let's add a button that allows us to connect to MetaMask. We don't have to write any code that's wallet-specific, fortunately, since ThirdWeb does it for us with the ConnectWallet component.

<ThirdwebProvider
activeChain={Kanazawa}
clientId="your-client-id"
>
    <ConnectWallet /> // Add this to your code!
</ThirdwebProvider>

Now there should be a button in the center of your screen that connects your wallet to your frontend! Next, let's find out how we can read data from our smart contract.

Read Data from Smart Contracts

Reading from contracts is quite easy, as long as we know what we want to read. For our application, we will be reading the maximum amount of tokens that can be minted and the number of tokens that have already been minted. This way, we can display to our users how many tokens can still be minted and hopefully invoke some FOMO...

If you were just using JSON-RPC, you would use eth_call to get this data, but it's quite difficult to do this since you have to encode your requests in a non-straightforward method called ABI encoding. Fortunately, Ethers.js allows us to easily create objects that represent contracts in a human-readable way, so long as we have the ABI of the contract. And we have the ABI of the MintableERC20.sol contract, MintableERC20.json, within the artifacts directory of our Hardhat project!

So let's start by moving the MintableERC20.json file into our frontend directory. Every time you change and recompile the smart contract, you'll have to update the ABI in the frontend as well. Some projects will have developer setups that automatically pull ABIs from the same source, but in this case we will just copy it over:

|--artifacts
    |--@openzeppelin
    |--build-info
    |--contracts
        |--MintableERC20.sol
            |--MintableERC20.json // This is the file you're looking for!
            ...
|--cache
|--contracts
|--frontend
    |--public
    |--src
        |--MintableERC20.json // Copy the file to here!
        ...
    ...
...

Now that we have the ABI, we can use it to create a contract instance of MintableERC20.sol, which we'll use to retrieve token data.

Create a New Component

Let's create a new component called <InteractWithContract /> to make sure we don't store all of our code in the main <App /> folder. Create a new InteractWithContract.tsxfile in your frontend/src folder and add the following code:

export const InteractWithContract = () => {
    return <div>InteractWithContract</div>;
};

Now let's update the App.tsx to include that component.

import "./App.css";

import { ConnectWallet, ThirdwebProvider } from "@thirdweb-dev/react";

import { InteractWithContract } from "./InteractWithContract";
import { Kanazawa, Meld } from "@thirdweb-dev/chains";
import { Typography } from "@mui/material";

function App() {
    return (
        <ThirdwebProvider
            activeChain={Kanazawa}
            supportedChains={[Meld, Kanazawa]}
            clientId="your-client-id"
        >
            <ConnectWallet />
            <InteractWithContract />
        </ThirdwebProvider>
    );
}

export default App;

Create a Smart Contract Instance

Let's import the JSON file and the Ethers Contract object within App.js. We can create a contract object instance with an address and ABI, so replace INSERT_CONTRACT_ADDRESS with the address of the contract that you copied back when you deployed it:

import MintableERC20 from "./MintableERC20.json";
import { useContract } from "@thirdweb-dev/react";

export const InteractWithContract = () => {
    const { contract } = useContract(
        "YOUR_DEPLOYED_ADDRESS"
    );

    return <div>InteractWithContract</div>;
};

Interact with the Contract Interface to Read Supply Data

Now, we'll fetch the token supply data and display it from the smart contract using a useEffect hook and storing it in a contractData variable:

import { useEffect, useState } from "react";

import MintableERC20 from "./MintableERC20.json";
import { Typography } from "@mui/material";
import { ethers } from "ethers";
import { useContract } from "@thirdweb-dev/react";

export const InteractWithContract = () => {
    const { contract } = useContract(
        "YOUR_DEPLOYED_ADDRESS",
        MintableERC20.abi
    );

    const [contractData, setContractData] = useState({
        totalSupply: "",
        maxSupply: "",
    });

    useEffect(() => {
        async function getContractData() {
            if (contract) {
                const totalSupply = await contract.call("totalSupply");
                const maxSupply = await contract.call("MAX_TO_MINT");
                setContractData({
                    totalSupply: ethers.utils.formatEther(
                        totalSupply.toString()
                    ),
                    maxSupply: ethers.utils.formatEther(maxSupply.toString()),
                });
            }
        }
        getContractData();
    }, [contract]);

    return (
        <div>
            {contract && (
                <Typography variant="h6">
                    Total Supply: {contractData.totalSupply} /{" "}
                    {contractData.maxSupply}
                </Typography>
            )}
        </div>
    );
};

Notice that this component uses the contract.call function provided by the ThirdWeb SDK. This method existing in the contract object we created earlier accepts a string method and any relevant arguments for the read-only call and returns the output. While it required some setup, this one-liner is a lot simpler than the entire use_call RPC call that we would have had to do if we weren't using Ethers.js and ThirdWeb.

Also note that we're using a utility format called formatEther to format the output values instead of displaying them directly. This is because our token, like gas currencies, is stored as an unsigned integer with a fixed decimal point of 18 figures. The utility function helps format this value into a way that we, as humans, expect.

Now we can spice up our frontend and call the read-only functions in the contract. We'll update the frontend so that we have a place to display our supply data:

import "./App.css";

import { Box, Card, CardContent, Stack, Typography } from "@mui/material";
import { ConnectWallet, ThirdwebProvider } from "@thirdweb-dev/react";

import { InteractWithContract } from "./InteractWithContract";
import { Kanazawa } from "@thirdweb-dev/chains";

function App() {
    return (
        <ThirdwebProvider
            activeChain={Kanazawa}
            clientId="YOUR-CLIENT-ID"
        >
            <Box
                sx={{
                    position: "absolute",
                    top: 5,
                    right: 5,
                }}
            >
                <ConnectWallet />
            </Box>
            <Card sx={{ width: 500 }}>
                <CardContent>
                    <Stack spacing={1}>
                        <Typography variant="h5">Mint Your Token!</Typography>
                        <InteractWithContract />
                    </Stack>
                </CardContent>
            </Card>
        </ThirdwebProvider>
    );
}

export default App;

Our frontend should now display the correct data!

Send Transactions

Now for the most important part of all DApps: the state-changing transactions. This is where money moves, where tokens are minted, and value passes.

If you recall from our smart contract, we want to mint some tokens by calling the purchaseMint function with some native currency. So we're going to need:

  1. A text input that lets the user specify how much value to enter

  2. A button that lets the user initiate the transaction signature

Let's keep adding more features to our current component. First, we'll tackle the text input, which will require us to add the logic to store the number of tokens to mint and a text field element.

import { Box, Stack, TextField, Typography } from "@mui/material";
import { Web3Button, toWei, useContract } from "@thirdweb-dev/react";
import { useEffect, useState } from "react";

import MintableERC20 from "./MintableERC20.json";
import { ethers } from "ethers";

export const InteractWithContract = () => {
    const { contract } = useContract(
        "YOUR_DEPLOYED_ADDRESS",
        MintableERC20.abi
    );

    const [contractData, setContractData] = useState({
        totalSupply: "",
        maxSupply: "",
    });

    const [amountToMint, setAmountToMint] = useState(0);

    useEffect(() => {
        async function getContractData() {
            if (contract) {
                const totalSupply = await contract.call("totalSupply");
                const maxSupply = await contract.call("MAX_TO_MINT");
                setContractData({
                    totalSupply: ethers.utils.formatEther(
                        totalSupply.toString()
                    ),
                    maxSupply: ethers.utils.formatEther(maxSupply.toString()),
                });
            }
        }
        getContractData();
    }, [contract]);

    return (
        <div>
            {contract && (
                <Stack spacing={1}>
                    <Typography variant="h6">
                        Total Supply: {contractData.totalSupply} /{" "}
                        {contractData.maxSupply}
                    </Typography>
                    <Box
                        sx={{
                            display: "flex",
                            justifyContent: "center",
                            alignItems: "center",
                            gap: 2,
                        }}
                    >
                        <TextField
                            id="outlined-basic"
                            label="Enter Amount to Mint"
                            variant="outlined"
                            type="number"
                            onChange={(e) => {
                                setAmountToMint(parseInt(e.target.value));
                            }}
                        />
                    </Box>
                </Stack>
            )}
        </div>
    );
};

Next, we'll need to create the button to send the transaction, which will call the purchaseMint of our contract. Interacting with the contract will be a bit more difficult since you're likely not as familiar with it. We've already done a lot of setup in the previous sections, so it doesn't actually take too much code:

import { Box, Stack, TextField, Typography } from "@mui/material";
import { Web3Button, toWei, useContract } from "@thirdweb-dev/react";
import { useEffect, useState } from "react";

import MintableERC20 from "./MintableERC20.json";
import { ethers } from "ethers";

export const InteractWithContract = () => {
    const { contract, refetch } = useContract( // extract the refetch function!
        "YOUR_DEPLOYED_ADDRESS",
        MintableERC20.abi
    );
    
    // --- other logic ----

    return (
        <Web3Button
            contractAddress="YOUR_DEPLOYED_ADDRESS"
            contractAbi={MintableERC20.abi}
            action={async (mintContract) => {
                const receipt = await mintContract.call(
                    "purchaseMint",
                    [],
                    {
                        value: toWei(amountToMint.toString()),
                    }
                    );
                refetch();
                return receipt;
            }}
            >
            Mint
        </Web3Button>
    );
};
InteractWithContract.tsx file
import { Box, Stack, TextField, Typography } from "@mui/material";
import { Web3Button, toWei, useContract } from "@thirdweb-dev/react";
import { useEffect, useState } from "react";

import MintableERC20 from "./MintableERC20.json";
import { ethers } from "ethers";

export const InteractWithContract = () => {
    const { contract, refetch } = useContract(
        "YOUR_DEPLOYED_ADDRESS",
        MintableERC20.abi
    );

    const [contractData, setContractData] = useState({
        totalSupply: "",
        maxSupply: "",
    });

    const [amountToMint, setAmountToMint] = useState(0);

    useEffect(() => {
        async function getContractData() {
            if (contract) {
                const totalSupply = await contract.call("totalSupply");
                const maxSupply = await contract.call("MAX_TO_MINT");
                setContractData({
                    totalSupply: ethers.utils.formatEther(
                        totalSupply.toString()
                    ),
                    maxSupply: ethers.utils.formatEther(maxSupply.toString()),
                });
            }
        }
        getContractData();
    }, [contract]);

    return (
        <div>
            {contract && (
                <Stack spacing={1}>
                    <Typography variant="h6">
                        Total Supply: {contractData.totalSupply} /{" "}
                        {contractData.maxSupply}
                    </Typography>
                    <Box
                        sx={{
                            display: "flex",
                            justifyContent: "center",
                            alignItems: "center",
                            gap: 2,
                        }}
                    >
                        <TextField
                            id="outlined-basic"
                            label="Enter Amount to Mint"
                            variant="outlined"
                            type="number"
                            onChange={(e) => {
                                setAmountToMint(parseInt(e.target.value));
                            }}
                        />
                        <Web3Button
                            contractAddress="YOUR_DEPLOYED_ADDRESS"
                            contractAbi={MintableERC20.abi}
                            action={async (mintContract) => {
                                const receipt = await mintContract.call(
                                    "purchaseMint",
                                    [],
                                    {
                                        value: toWei(amountToMint.toString()),
                                    }
                                );
                                console.log(receipt);
                                refetch();
                                return receipt;
                            }}
                        >
                            Mint
                        </Web3Button>
                    </Box>
                </Stack>
            )}
        </div>
    );
};

Let's break down the Web3Button a bit:

  1. contractAddress and contractAbi are props that define which contract the button will interact with.

  2. The action prop is an asynchronous function that gets executed when the button is clicked. It calls the purchaseMint function of the contract, passing the amount to mint (converted to Wei using toWei).

  3. refetch is called after the transaction to update the local state with the latest contract data which will show a larger value after the mint has happened.

  4. We don't have to handle any loading/disabled states because the Web3Button handles this for us!

That's pretty much it! Your Dapp should probably now look something like this.

If you try entering a value like 0.1 (make sure to have 0.1 tgMELD in your wallet) and press the button, a MetaMask prompt should occur. Try it out!

Read Events from Contracts

A common way of listening to what happened in a transaction is through events, also known as logs. These logs are emitted by the smart contract through the emit and event keywords and can be very important in a responsive frontend. Often, DApps will use toast elements to represent events in real-time, but for this DApp, we will use a simple table.

We created an event in our smart contract: event PurchaseOccurred(address minter, uint256 amount), so let's figure out how to display its information in the frontend.

Let's add some additional logic to handle events emitted:

// --- various imports --

export const InteractWithContract = () => {
    // -- other code --

    // add events to the contractData object
    const [contractData, setContractData] = useState({
        totalSupply: "",
        maxSupply: "",
        events: [] as ContractEvent<Record<string, any>>[],
    });

    useEffect(() => {
        async function getContractData() {
            if (contract) {
                const totalSupply = await contract.call("totalSupply");
                const maxSupply = await contract.call("MAX_TO_MINT");

                // get all events emitted by the contract and filter by PurchaseOccurred
                const events = (await contract.events.getAllEvents()).filter(
                    (event) => {
                        return event.eventName === "PurchaseOccurred";
                    }
                );

                setContractData({
                    totalSupply: ethers.utils.formatEther(
                        totalSupply.toString()
                    ),
                    maxSupply: ethers.utils.formatEther(maxSupply.toString()),
                    events: events,
                });
            }
        }
        getContractData();
    }, [contract]);

    return (
        <div>
                   // -- other code --
                    <Typography variant="h6">Events</Typography>
                    {contractData.events.map((event) => {
                        return (
                            <Stack>
                                <Box
                                    sx={{
                                        display: "flex",
                                        justifyContent: "space-between",
                                        gap: 2,
                                    }}
                                >
                                    <Typography variant="subtitle1">
                                        Minter
                                    </Typography>
                                    <Typography variant="subtitle1">
                                        Amount
                                    </Typography>
                                </Box>
                                <Box
                                    sx={{
                                        display: "flex",
                                        justifyContent: "space-between",
                                        gap: 2,
                                    }}
                                    key={event.transaction.transactionHash}
                                >
                                    <Typography variant="subtitle1">
                                        {event.data.minter.slice(0, 6)}...
                                        {event.data.minter.slice(-4)}
                                    </Typography>
                                    <Typography variant="subtitle1">
                                        {ethers.utils.formatEther(
                                            event.data.amount.toString()
                                        )}
                                    </Typography>
                                </Box>
                            </Stack>
            )}
            // -- other code --
        </div>
    );
};

Here's what happens in this code:

  1. The state holds data related to the contract: totalSupply, maxSupply, and now an array of events.

  2. The useEffect now calls the getAllEvents() contract function and filters for all events with any arguments on the contract with the event name PurchaseOccurred.

  3. We now render the list of events from the contractData. For each event, we display information such as the minter's address and the amount, formatted for better readability.

InteractWithContract.tsx file
import { Box, Stack, TextField, Typography } from "@mui/material";
import {
    ContractEvent,
    ContractEvents,
    Transaction,
    Web3Button,
    toWei,
    useContract,
} from "@thirdweb-dev/react";
import { useEffect, useState } from "react";

import MintableERC20 from "./MintableERC20.json";
import { ethers } from "ethers";

export const InteractWithContract = () => {
    const { contract, refetch } = useContract(
        "YOUR_DEPLOYED_ADDRESS",
        MintableERC20.abi
    );

    const [contractData, setContractData] = useState({
        totalSupply: "",
        maxSupply: "",
        // give events the correct type
        events: [] as ContractEvent<Record<string, any>>[],
    });

    const [amountToMint, setAmountToMint] = useState(0);

    useEffect(() => {
        async function getContractData() {
            if (contract) {
                const totalSupply = await contract.call("totalSupply");
                const maxSupply = await contract.call("MAX_TO_MINT");

                const events = (await contract.events.getAllEvents()).filter(
                    (event) => {
                        return event.eventName === "PurchaseOccurred";
                    }
                );
                console.log("events:", events);

                setContractData({
                    totalSupply: ethers.utils.formatEther(
                        totalSupply.toString()
                    ),
                    maxSupply: ethers.utils.formatEther(maxSupply.toString()),
                    events: events,
                });
            }
        }
        getContractData();
    }, [contract]);

    return (
        <div>
            {contract && (
                <Stack spacing={1}>
                    <Typography variant="h6">
                        Total Supply: {contractData.totalSupply} /{" "}
                        {contractData.maxSupply}
                    </Typography>
                    <Box
                        sx={{
                            display: "flex",
                            justifyContent: "center",
                            alignItems: "center",
                            gap: 2,
                        }}
                    >
                        <TextField
                            id="outlined-basic"
                            label="Enter Amount to Mint"
                            variant="outlined"
                            type="number"
                            onChange={(e) => {
                                setAmountToMint(parseInt(e.target.value));
                            }}
                        />
                        <Web3Button
                            contractAddress="YOUR_DEPLOYED_ADDRESS"
                            contractAbi={MintableERC20.abi}
                            action={async (mintContract) => {
                                const receipt = await mintContract.call(
                                    "purchaseMint",
                                    [],
                                    {
                                        value: toWei(amountToMint.toString()),
                                    }
                                );
                                refetch();
                                return receipt;
                            }}
                        >
                            Mint
                        </Web3Button>
                    </Box>
                    <Typography variant="h6">Events</Typography>
                    {contractData.events.map((event) => {
                        return (
                            <Stack>
                                <Box
                                    sx={{
                                        display: "flex",
                                        justifyContent: "space-between",
                                        gap: 2,
                                    }}
                                >
                                    <Typography variant="subtitle1">
                                        Minter
                                    </Typography>
                                    <Typography variant="subtitle1">
                                        Amount
                                    </Typography>
                                </Box>
                                <Box
                                    sx={{
                                        display: "flex",
                                        justifyContent: "space-between",
                                        gap: 2,
                                    }}
                                    key={event.transaction.transactionHash}
                                >
                                    <Typography variant="subtitle1">
                                        {event.data.minter.slice(0, 6)}...
                                        {event.data.minter.slice(-4)}
                                    </Typography>
                                    <Typography variant="subtitle1">
                                        {ethers.utils.formatEther(
                                            event.data.amount.toString()
                                        )}
                                    </Typography>
                                </Box>
                            </Stack>
                        );
                    })}
                </Stack>
            )}
        </div>
    );
};

And, if you've done any transactions, you'll see that they'll pop up!

Now you've implemented three main components of DApp frontends: reading from storage, sending transactions, and reading logs. With these building blocks as well as the knowledge you gained with smart contracts and nodes, you should be able to cover 80% of DApps.

You can view the complete example DApp on GitHub.

Conclusion

Last updated