DEV Community

How to create a Web3 Wallet Like MetaMask? Web3 and Blockchain Project.

Here we will create a web3 wallet like MetaMask using tech stacks like Ethers.js(v5), React, TypeScript and Tailwind CSS.

For reference I recommend you to watch this YouTube video and this GitHub Repo(Source Code). In the video I have also explained how you can create a Browser extension.

Our Wallet has 3 parts:

Image description

  1. Create New account & Recover existing account
  2. Fetching balance and sending ETH
  3. Fetching transaction data from block explorer

Prerequisite: Knowledge of Web3, React.js and Backend Dev

1. Create New account & Recover existing account:

For account creation and recover we will create React component AccountCreate.tsx:

import React, { useState, useEffect } from "react";
import { generateAccount } from "../wallet-utils/AccountUtils";

import AccountDetails from "./AccountDetails";
import TransactionDetails from "./TransactionDetails";

interface Account {
  privateKey: string;
  address: string;
  balance: string;
}

const AccountCreate: React.FC = () => {
  const [showInput, setShowInput] = useState(false);
  const [seedPhrase, setSeedPhrase] = useState("");
  const [account, setAccount] = useState<Account | null>(null);

  const createAccount = () => {
    const account = generateAccount();// account object contains--> address, privateKey, seedPhrase, balance
    console.log("Account created!", account);
    setSeedPhrase(account.seedPhrase);
    setAccount(account.account);
  };

  const showInputFunction = () => {
    setShowInput(true);
  };

  const handleSeedPhraseChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSeedPhrase(e.target.value);
  };

  const handleSeedPhraseSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const account = generateAccount(seedPhrase);
    console.log("Recovery", account);
    setSeedPhrase(account.seedPhrase);
    setAccount(account.account);
  };

  return (
    <div className="max-w-md mx-auto bg-white rounded-md shadow-md p-6">
      <h2 className="text-2xl font-bold mb-4">Pixel Web3 Wallet on Polygon Mumbai</h2>
      <button
        onClick={createAccount}
        className="text-white bg-gradient-to-r from-purple-500 via-purple-600 to-purple-700 hover:bg-gradient-to-br focus:ring-4 focus:outline-none focus:ring-purple-300 dark:focus:ring-purple-800 shadow-lg shadow-purple-500/50 dark:shadow-lg dark:shadow-purple-800/80 font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 mb-2"
      >
        Create Account
      </button>
      <button
        onClick={showInputFunction}
        className="text-gray-900 bg-gradient-to-r from-lime-200 via-lime-400 to-lime-500 hover:bg-gradient-to-br focus:ring-4 focus:outline-none focus:ring-lime-300 dark:focus:ring-lime-800 shadow-lg shadow-lime-500/50 dark:shadow-lg dark:shadow-lime-800/80 font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 mb-2"
      >
        Recover Account
      </button>
      {showInput && (
        <form onSubmit={handleSeedPhraseSubmit} className="flex m-2">
          <input
            type="text"
            value={seedPhrase}
            onChange={handleSeedPhraseChange}
            className="bg-transparent border border-gray-300 rounded-md w-full py-2 px-4 placeholder-gray-400 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 mr-2"
            placeholder="Enter your text"
          />
          <button
            type="submit"
            className="text-white bg-gradient-to-r from-cyan-400 via-cyan-500 to-cyan-600 hover:bg-gradient-to-br focus:ring-4 focus:outline-none focus:ring-cyan-300 dark:focus:ring-cyan-800 shadow-lg shadow-cyan-500/50 dark:shadow-lg dark:shadow-cyan-800/80 font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 m-2"
          >
            Submit
          </button>
        </form>
      )}

      <div>
        <p className=" text-gray-900 font-medium">A/C Address: </p>
        <span className="text-gray-600 mt-2">{account?.address}</span>
      </div>

      <div>
        <p className="text-gray-900  font-medium">Your 12 Phrase Mnemonic: </p>
        <span className="text-gray-600 text-normal">{seedPhrase}</span>
      </div>

      <hr />
      {account && <AccountDetails account={account} />}
      {account && <TransactionDetails address={account.address} />}
    </div>
  );
};

export default AccountCreate;

Enter fullscreen mode Exit fullscreen mode

The above component has an import named generateAccount, which is also the most important function. For that we will refer ā€˜AccountUtils.tsā€™ file. We will install npm install ethers@5 . The code is:

import { Wallet } from "ethers";

interface Account {
  privateKey: string;
  address: string;
  balance: string;
}

export function generateAccount(
  seedPhrase: string = "",
  index: number = 0
): { account: Account; seedPhrase: string } {
  let wallet: Wallet;

  if (seedPhrase === "") {
    seedPhrase = Wallet.createRandom().mnemonic.phrase;
  }

  // If the seed phrase does not contain spaces, it is likely a mnemonic
  wallet = seedPhrase.includes(" ")
    ? Wallet.fromMnemonic(seedPhrase, `m/44'/60'/0'/0/${index}`)
    : new Wallet(seedPhrase);

  // console.log("hehe",wallet);

  const { address } = wallet; // we are capturing address variable from 'wallet' object

  const account = { address, privateKey: wallet.privateKey, balance: "0" };

  // If the seedphrase does not include spaces then it's actually a private key, so return a blank string.
  return { account, seedPhrase: seedPhrase.includes(" ") ? seedPhrase : "" };
}
Enter fullscreen mode Exit fullscreen mode

This sums up our first feature that is create and recover accounts!! Letā€™s move to second one.

2. Fetching balance and sending ETH

For fetching balance and sending ETH we will create a React component and also create an interface of chains on Ethereum network.

At the beginning we will create AccountDetails.tsx component:

import React, { useState, useEffect } from "react";
import { Account } from "../interfaces/Account";
import { ethers } from "ethers";
import { mumbai } from "../interfaces/Chain";
import { sendToken } from "../wallet-utils/TransactionUtils";

interface AccountDetailProps {
  account: Account;
}

const AccountDetails: React.FC<AccountDetailProps> = ({ account }) => {
  const [destinationAddress, setDestinationAddress] = useState("");
  const [amount, setAmount] = useState(0);
  const [balance, setBalance] = useState(account.balance);
  const [networkResponse, setNetworkResponse] = useState<{
    status: null | "pending" | "complete" | "error";
    message: string | React.ReactElement;
  }>({
    status: null,
    message: "",
  });

  const fetchData = async () => {
    const provider = new ethers.providers.JsonRpcProvider(mumbai.rpcUrl);
    let accountBalance = await provider.getBalance(account.address);
    setBalance(
      String(formatEthFunc(ethers.utils.formatEther(accountBalance)))
    );
  };

  //Required to Format decimals of ETH balance
  function formatEthFunc(value: string, decimalPlaces: number = 2) {
    return +parseFloat(value).toFixed(decimalPlaces);
  }

  useEffect(() => {
    fetchData();
  }, [account.address]);

  const handleDestinationAddressChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    setDestinationAddress(e.target.value);
  };

  const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setAmount(Number.parseFloat(e.target.value));
  };

  const transfer = async () => {
    setNetworkResponse({
      status: "pending",
      message: "",
    });

    try {
      const { receipt } = await sendToken(
        amount,
        account.address,
        destinationAddress,
        account.privateKey
      );

      if (receipt.status === 1) {
        setNetworkResponse({
          status: "complete",
          message: (
            <p>
              Transfer complete!{" "}
              <a
                href={`${mumbai.blockExplorerUrl}/tx/${receipt.transactionHash}`}
                target="_blank"
                rel="noreferrer"
              >
                View transaction
              </a>
            </p>
          ),
        });
        return receipt;
      } else {
        console.log(`Failed to send ${receipt}`);
        // Set the network response status to "error" and the message to the receipt
        setNetworkResponse({
          status: "error",
          message: JSON.stringify(receipt),
        });
        return { receipt };
      }
    } catch (error: any) {
      console.error(error);
      setNetworkResponse({
        status: "error",
        message: error.reason || JSON.stringify(error),
      });
    }
  };

  return (
    <div className="">
      <div>
        <h4 className="text-gray-900 font-medium">Address: </h4>
        <a
          target="blank"
          href={`https://mumbai.polygonscan.com/address/${account.address}`}
          className="text-blue-500 hover:text-blue-600  cursor-pointer"
        >
          {account.address}
        </a>
        <br />
        <span className="text-gray-900 font-medium">Balance: </span>
        {balance} ETH
      </div>

      <div className="my-2">
        <label htmlFor="" className="text-gray-900 font-medium">
          Destination Address:
        </label>
        <input
          type="text"
          value={destinationAddress}
          onChange={handleDestinationAddressChange}
          className="border"
        />
      </div>

      <div>
        <label htmlFor="" className="text-gray-900 font-medium">
          Amount:
        </label>
        <input
          type="number"
          value={amount}
          onChange={handleAmountChange}
          className="border"
        />
      </div>

      <button
        className="text-white bg-gradient-to-r from-yellow-400 via-yellow-500 to-yellow-600 hover:bg-gradient-to-br focus:ring-4 focus:outline-none focus:ring-yellow-300 dark:focus:ring-yellow-800 shadow-lg shadow-yellow-500/50 dark:shadow-lg dark:shadow-yellow-800/80 font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 m-2"
        type="button"
        onClick={transfer}
        disabled={!amount || networkResponse.status === "pending"}
      >
        Send {amount} ETH
      </button>

      {networkResponse.status && (
        <>
          {networkResponse.status === "pending" && (
            <p>Transfer is pending...</p>
          )}
          {networkResponse.status === "complete" && (
            <p>{networkResponse.message}</p>
          )}
          {networkResponse.status === "error" && (
            <p>
              Error occurred while transferring tokens:{" "}
              {networkResponse.message}
            </p>
          )}
        </>
      )}
    </div>
  );
};

export default AccountDetails;
Enter fullscreen mode Exit fullscreen mode

If you notice, in the above file we have few interface imports.

a. Import of account data types, create a file Account.ts:

export interface Account {
    privateKey: string,
    address: string,
    balance: string,
}
Enter fullscreen mode Exit fullscreen mode

b. Import of different networks on Ethereum, create a file Chain.ts:
Remember in this file youā€™ll need RPC-URL of the networks, you can get them from Alchemy or Infura or from somewhere else.

export type Chain = {
    chainId: string;
    name: string;
    blockExplorerUrl: string;
    rpcUrl: string;
  };

export const mumbai: Chain = {
    chainId: '80001',
    name: 'Polygon Testnet Mumbai',
    blockExplorerUrl: 'https://mumbai.polygonscan.com ',
    rpcUrl: '<YOUR-RPC-URL>',
};

export const mainnet: Chain = {
    chainId: '1',
    name: 'Ethereum',
    blockExplorerUrl: 'https://etherscan.io',
    rpcUrl: '<YOUR-RPC-URL>',
};

export const CHAINS_CONFIG = {
    [mumbai.chainId]: mumbai,
    [mainnet.chainId]: mainnet,
};
Enter fullscreen mode Exit fullscreen mode

c. Third imort on AccountDetails.tsx file is TransactionUtils.ts, which contains the most import function that will be used to transfer ETH.

import { ethers, Wallet } from "ethers";
import { CHAINS_CONFIG, mumbai } from "../interfaces/Chain";

export async function sendToken(
  amount: number,
  from: string,
  to: string,
  privateKey: string
) {
  const chain = CHAINS_CONFIG[mumbai.chainId];
  const provider = new ethers.providers.JsonRpcProvider(chain.rpcUrl);//creating a
  const wallet: Wallet = new ethers.Wallet(privateKey, provider);

  const tx = { to, value: ethers.utils.parseEther(amount.toString()) };

  const transaction = await wallet.sendTransaction(tx);

  const receipt = await transaction.wait();

  return { transaction, receipt };
}
Enter fullscreen mode Exit fullscreen mode

End of the second, now comes the Third part.

3. Fetching transaction data from block explorer

For this weā€™ll need API-Key-Token from the block explorer. You can refer the video mentioned in the beginning.

Create a file TransactionDetails.tsx. Here youā€™ll need to have basic understandings of backend development, as we are fetching data using axios.

We are using base uri and endpoints of Polygonscan as we are fetching data from mumbai test net of polygon.

import React, { useEffect, useState } from "react";
import axios from "axios";

interface Transaction {
  hash: string;
  from: string;
  to: string;
  isError: string;
  timeStamp: string;
}

interface TransactionTableProps {
  address: string;
}

const MUMBAI_API_KEY = "<YOUR-API-KEY>";
const MUMBAI_API_BASE_URL = "https://api-testnet.polygonscan.com/api";

const TransactionDetails: React.FC<TransactionTableProps> = ({ address }) => {
  const [transactions, setTransactions] = useState<Transaction[]>([]);

  useEffect(() => {
    const fetchTransactions = async () => {
      const endpoint = `?module=account&action=txlist&address=${address}&page=1&offset=10&sort=desc&apikey=${MUMBAI_API_KEY}`;
      const url = `${MUMBAI_API_BASE_URL}${endpoint}`;

      try {
        const response = await axios.get(url);
        const transactionData: Transaction[] = response.data.result;
        setTransactions(transactionData);
      } catch (error) {
        console.error("Error fetching transactions:", error);
      }
    };

    fetchTransactions();
  }, [address]);

  return (
    <table className="min-w-full divide-y divide-gray-200">
    <thead className="bg-gray-100">
      <tr>
        <th className="py-2 px-4">No.</th>
        <th className="py-2 px-4">Hash</th>
        <th className="py-2 px-4">From</th>
        <th className="py-2 px-4">To</th>
        <th className="py-2 px-4">Status</th>
        <th className="py-2 px-4">Timestamp</th>
      </tr>
    </thead>
    <tbody>
      {transactions.map((transaction, index) => (
        <tr key={index} className={index % 2 === 0 ? 'bg-gray-50' : ''}>
          <td className="py-2 px-4">{index + 1}</td>
          <td className="py-2 px-4">
            {`${transaction.hash.slice(0, 5)}...${transaction.hash.slice(-3)}`}
          </td>
          <td className="py-2 px-4">
            {`${transaction.from.slice(0, 5)}...${transaction.from.slice(-3)}`}
          </td>
          <td className="py-2 px-4">
            {`${transaction.to.slice(0, 5)}...${transaction.to.slice(-3)}`}
          </td>
          <td className="py-2 px-4">
            {transaction.isError === '0' ? 'āœ…' : 'āŒ'}
          </td>
          <td className="py-2 px-4">{transaction.timeStamp}</td>
        </tr>
      ))}
    </tbody>
  </table>
  );
};

export default TransactionDetails;
Enter fullscreen mode Exit fullscreen mode

This sums up the coding part.


This is how wallet browser extension looks like, steps are explained in the YT video.

Image description

If you stuck anywhere, hit me up

Top comments (0)