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

Sourin
7 min readJun 20, 2023

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:

  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;

//text-gray-600 mt-2

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 : "" };
}

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;

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,
}

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,
};

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 };
}

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;

This sums of the coding part.

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

If you stuck anywhere, hit me up

--

--