Build a Frontend dApp This tutorial uses a single frontend architecture based on React Context. Follow all frontend steps in order to avoid duplication .
This tutorial walks you through building a complete decentralized application (dApp) on Shardeum — from deploying a sample smart contract to interacting with it through a React-based frontend.
You will:
Deploy a sample ERC20 token contract on Shardeum
Connect a wallet (MetaMask)
Read on-chain data (balances)
Send transactions (token transfers)
View transactions on the Shardeum explorer
This guide is fully self-contained and does not assume prior deployments.
Let's build a token interface dApp that allows users to:
Connect their wallet
View token balances
Transfer tokens to another address
Track transactions using the Shardeum explorer
Before starting, ensure you have:
Node.js (v18 or later) - Download
MetaMask wallet extension - Install
Test SHM tokens - Get from faucet
Code Editor - VS Code recommended
Basic familiarity with React and JavaScript or TypeScript
Testing First: Always deploy to testnet before mainnet. Use Shardeum EVM Testnet for development.
This tutorial uses a single frontend architecture based on React Context. Follow all frontend steps in order to avoid duplication .
This tutorial walks you through building a complete decentralized application (dApp) on Shardeum — from deploying a sample smart contract to interacting with it through a React-based frontend.
You will:
Deploy a sample ERC20 token contract on Shardeum
Connect a wallet (MetaMask)
Read on-chain data (balances)
Send transactions (token transfers)
View transactions on the Shardeum explorer
This guide is fully self-contained and does not assume prior deployments.
Let's build a token interface dApp that allows users to:
Connect their wallet
View token balances
Transfer tokens to another address
Track transactions using the Shardeum explorer
Before starting, ensure you have:
Node.js (v18 or later) - Download
MetaMask wallet extension - Install
Test SHM tokens - Get from faucet
Code Editor - VS Code recommended
Basic familiarity with React and JavaScript or TypeScript
Testing First: Always deploy to testnet before mainnet. Use Shardeum EVM Testnet for development.
To interact with the blockchain, we first need something deployed on-chain. For this tutorial, we’ll deploy a simple ERC20 token contract.
Create a file called MyToken.sol. You will create this file after initializing the Hardhat project in the next step.
Create contracts/MyToken.sol and paste the code below:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20 ;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol" ;
contract MyToken is ERC20 {
constructor () ERC20 ("My Token", "MTK") {
_mint ( msg.sender , 1_000_000 * 10 ** decimals ());
}
}
This contract:
Mints 1 million tokens to the deployer
Supports balance checks
Supports token transfers
Initialize a Hardhat project:
mkdir shardeum-contract
cd shardeum-contract
npm init -y
npm install --save-dev hardhat
When Hardhat prompts, choose “TypeScript project”. If npx hardhat init doesn’t work on your version, run npx hardhat and choose “Create a TypeScript project” instead.
npx hardhat init
npm install --save-dev @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts
npm install dotenv
You can now create contracts/MyToken.sol and paste the contract code from Step 1.
Next, create a .env file:
PRIVATE_KEY = 0xyour_private_key_here
Update hardhat.config.ts:
import { HardhatUserConfig } from "hardhat/config" ;
import "@nomicfoundation/hardhat-toolbox" ;
import "dotenv/config" ;
const config : HardhatUserConfig = {
solidity: "0.8.20" ,
networks: {
shardeumTestnet: {
url: "https://api-mezame.shardeum.org" ,
chainId: 8119 ,
accounts: [process.env. PRIVATE_KEY ! ],
},
},
};
export default config;
Create scripts/deploy.ts:
import { ethers } from "hardhat" ;
async function main () {
const Token = await ethers. getContractFactory ( "MyToken" );
const token = await Token. deploy ();
await token. waitForDeployment ();
console. log ( "Token deployed to:" , await token. getAddress ());
}
main (). catch (( err ) => {
console. error (err);
process. exit ( 1 );
});
Deploy
npx hardhat run scripts/deploy.ts --network shardeumTestnet
Save the deployed contract address . You’ll need it in the frontend.
Now we’ll build a frontend that interacts with the deployed contract.
npm create vite@latest shardeum-dapp -- --template react-ts
cd shardeum-dapp
npm install
npm install ethers
Create src/config/networks.ts:
export const SHARDEUM_TESTNET = {
chainId: "0x1fb7" , // 8119
chainName: "Shardeum EVM Testnet" ,
nativeCurrency: {
name: "SHM" ,
symbol: "SHM" ,
decimals: 18 ,
},
rpcUrls: [ "https://api-mezame.shardeum.org" ],
blockExplorerUrls: [ "https://explorer-mezame.shardeum.org" ],
};
export const TOKEN_ADDRESS = "0xYOUR_CONTRACT_ADDRESS_HERE" ;
Create src/config/erc20Abi.ts:
export const ERC20_ABI = [
"function name() view returns (string)" ,
"function symbol() view returns (string)" ,
"function decimals() view returns (uint8)" ,
"function balanceOf(address) view returns (uint256)" ,
"function transfer(address to, uint256 amount) returns (bool)" ,
"event Transfer(address indexed from, address indexed to, uint256 value)"
];
Create src/context/WalletContext.tsx:
import { createContext, useContext, useState, useEffect, ReactNode } from 'react' ;
import { ethers, BrowserProvider, Eip1193Provider } from 'ethers' ;
import { SHARDEUM_TESTNET } from '../config/networks' ;
interface WalletContextType {
account : string | null ;
chainId : string | null ;
provider : BrowserProvider | null ;
connectWallet : () => Promise < void >;
disconnectWallet : () => void ;
switchNetwork : () => Promise < void >;
}
const WalletContext = createContext < WalletContextType | undefined >( undefined );
export function WalletProvider ({ children } : { children : ReactNode }) {
const [ account , setAccount ] = useState < string | null >( null );
const [ chainId , setChainId ] = useState < string | null >( null );
const [ provider , setProvider ] = useState < BrowserProvider | null >( null );
// Check if wallet is connected on mount
useEffect (() => {
const checkConnection = async () => {
if (window.ethereum) {
const provider = new ethers. BrowserProvider (window.ethereum as Eip1193Provider );
const accounts = await provider. send ( 'eth_accounts' , []);
if (accounts. length > 0 ) {
setAccount (accounts[ 0 ]);
setProvider (provider);
const network = await provider. getNetwork ();
setChainId ( '0x' + network.chainId. toString ( 16 ));
}
}
};
checkConnection ();
// Listen for account changes
if (window.ethereum) {
window.ethereum. on ( 'accountsChanged' , ( accounts : string []) => {
if (accounts. length > 0 ) {
setAccount (accounts[ 0 ]);
} else {
disconnectWallet ();
}
});
window.ethereum. on ( 'chainChanged' , ( chainId : string ) => {
setChainId (chainId);
window.location. reload ();
});
}
return () => {
if (window.ethereum) {
window.ethereum. removeAllListeners ( 'accountsChanged' );
window.ethereum. removeAllListeners ( 'chainChanged' );
}
};
}, []);
const connectWallet = async () => {
if ( ! window.ethereum) {
alert ( 'Please install MetaMask!' );
return ;
}
try {
const provider = new ethers. BrowserProvider (window.ethereum as Eip1193Provider );
const accounts = await provider. send ( 'eth_requestAccounts' , []);
setAccount (accounts[ 0 ]);
setProvider (provider);
const network = await provider. getNetwork ();
setChainId ( '0x' + network.chainId. toString ( 16 ));
} catch (error) {
console. error ( 'Failed to connect wallet:' , error);
}
};
const disconnectWallet = () => {
setAccount ( null );
setProvider ( null );
setChainId ( null );
};
const switchNetwork = async () => {
if ( ! window.ethereum) return ;
try {
await window.ethereum. request ({
method: 'wallet_switchEthereumChain' ,
params: [{ chainId: SHARDEUM_TESTNET .chainId }],
});
} catch ( switchError : any ) {
// This error code indicates that the chain has not been added to MetaMask
if (switchError.code === 4902 ) {
try {
await window.ethereum. request ({
method: 'wallet_addEthereumChain' ,
params: [ SHARDEUM_TESTNET ],
});
} catch (addError) {
console. error ( 'Failed to add network:' , addError);
}
} else {
console. error ( 'Failed to switch network:' , switchError);
}
}
};
return (
< WalletContext.Provider
value = {{
account,
chainId,
provider,
connectWallet,
disconnectWallet,
switchNetwork
}}
>
{ children }
</ WalletContext.Provider >
);
}
export function useWallet () {
const context = useContext (WalletContext);
if (context === undefined ) {
throw new Error ( 'useWallet must be used within a WalletProvider' );
}
return context;
}
// Extend Window type for TypeScript
declare global {
interface Window {
ethereum ?: Eip1193Provider ;
}
}
Create src/components/TokenInterface.tsx:
import { useState, useEffect } from 'react' ;
import { ethers, Contract } from 'ethers' ;
import { useWallet } from '../context/WalletContext' ;
import { TOKEN_ADDRESS } from '../config/networks' ;
import { ERC20_ABI } from '../config/erc20Abi' ;
export function TokenInterface () {
const { account , provider } = useWallet ();
const [ tokenName , setTokenName ] = useState ( '' );
const [ tokenSymbol , setTokenSymbol ] = useState ( '' );
const [ balance , setBalance ] = useState ( '0' );
const [ recipient , setRecipient ] = useState ( '' );
const [ amount , setAmount ] = useState ( '' );
const [ loading , setLoading ] = useState ( false );
const [ txHash , setTxHash ] = useState ( '' );
useEffect (() => {
if (account && provider) {
loadTokenInfo ();
loadBalance ();
}
}, [account, provider]);
const loadTokenInfo = async () => {
if ( ! provider) return ;
try {
const contract = new Contract ( TOKEN_ADDRESS , ERC20_ABI , provider);
const name = await contract. name ();
const symbol = await contract. symbol ();
setTokenName (name);
setTokenSymbol (symbol);
} catch (error) {
console. error ( 'Failed to load token info:' , error);
}
};
const loadBalance = async () => {
if ( ! provider || ! account) return ;
try {
const contract = new Contract ( TOKEN_ADDRESS , ERC20_ABI , provider);
const balance = await contract. balanceOf (account);
setBalance (ethers. formatEther (balance));
} catch (error) {
console. error ( 'Failed to load balance:' , error);
}
};
const handleTransfer = async ( e : React . FormEvent ) => {
e. preventDefault ();
if ( ! provider || ! account) return ;
setLoading ( true );
setTxHash ( '' );
try {
const signer = await provider. getSigner ();
const contract = new Contract ( TOKEN_ADDRESS , ERC20_ABI , signer);
const tx = await contract. transfer (
recipient,
ethers. parseEther (amount)
);
setTxHash (tx.hash);
await tx. wait ();
alert ( 'Transfer successful!' );
loadBalance ();
setRecipient ( '' );
setAmount ( '' );
} catch ( error : any ) {
console. error ( 'Transfer failed:' , error);
alert ( 'Transfer failed: ' + error.message);
} finally {
setLoading ( false );
}
};
if ( ! account) {
return (
< div className = "card" >
< h2 >Token Interface </ h2 >
< p >Please connect your wallet to use the token interface. </ p >
</ div >
);
}
return (
< div className = "card" >
< h2 >{tokenName} ({tokenSymbol}) </ h2 >
< div className = "balance" >
< p >Your Balance : </ p >
< h3 >{balance} { tokenSymbol } </ h3 >
</ div >
< form onSubmit = {handleTransfer} className = "transfer-form" >
< h3 >Transfer Tokens </ h3 >
< div className = "form-group" >
< label >Recipient Address : </ label >
< input
type = "text"
value = {recipient}
onChange = {(e) => setRecipient (e.target.value)}
placeholder = "0x..."
required
/>
</ div >
< div className = "form-group" >
< label > Amount : </ label >
< input
type = "number"
value = {amount}
onChange = {(e) => setAmount (e.target.value)}
placeholder = "0.0"
step = "0.000001"
required
/>
</ div >
< button type = "submit" disabled = {loading} >
{ loading ? 'Processing...' : 'Transfer' }
</ button >
{ txHash && (
< div className = "tx-hash" >
< p > Transaction Hash :</ p >
< a
href = { `https://explorer-mezame.shardeum.org/tx/${ txHash }` }
target = "_blank"
rel = "noopener noreferrer"
>
{ txHash . slice (0, 10)} ... { txHash . slice (-8)}
</ a >
</ div >
)}
</ form >
</ div >
);
}
Update src/App.tsx:
import { useWallet } from './context/WalletContext' ;
import { TokenInterface } from './components/TokenInterface' ;
import { SHARDEUM_TESTNET } from './config/networks' ;
import './App.css' ;
function App () {
const { account , chainId , connectWallet , disconnectWallet , switchNetwork } = useWallet ();
const isCorrectNetwork = chainId === SHARDEUM_TESTNET .chainId;
return (
< div className = "App" >
< header className = "App-header" >
< h1 >Shardeum dApp </ h1 >
< div className = "wallet-controls" >
{! account ? (
< button onClick = {connectWallet} className = "connect-btn" >
Connect Wallet
</ button >
) : (
< div className = "wallet-info" >
< p > Connected : { account . slice (0, 6)} ... { account . slice (-4)}</ p >
< button onClick = {disconnectWallet} className = "disconnect-btn" >
Disconnect
</ button >
</ div >
)}
</ div >
{ account && ! isCorrectNetwork && (
< div className = "network-warning" >
< p >⚠️ Wrong Network </ p >
< button onClick = {switchNetwork} > Switch to Shardeum Testnet </ button >
</ div >
)}
</ header >
< main >
{ account && isCorrectNetwork && < TokenInterface />}
</ main >
</ div >
);
}
export default App;
Update src/main.tsx:
import React from 'react' ;
import ReactDOM from 'react-dom/client' ;
import App from './App.tsx' ;
import { WalletProvider } from './context/WalletContext.tsx' ;
import './index.css' ;
ReactDOM. createRoot (document. getElementById ( 'root' ) ! ). render (
< React.StrictMode >
< WalletProvider >
< App />
</ WalletProvider >
</ React.StrictMode > ,
);
Create src/App.css:
.App {
max-width : 1200 px ;
margin : 0 auto ;
padding : 2 rem ;
}
.App-header {
text-align : center ;
margin-bottom : 2 rem ;
}
.wallet-controls {
margin-top : 1 rem ;
}
.connect-btn ,
.disconnect-btn {
background : #646cff ;
color : white ;
border : none ;
padding : 0.75 rem 1.5 rem ;
border-radius : 8 px ;
cursor : pointer ;
font-size : 1 rem ;
transition : background 0.3 s ;
}
.connect-btn:hover ,
.disconnect-btn:hover {
background : #535bf2 ;
}
.wallet-info {
display : inline-flex ;
align-items : center ;
gap : 1 rem ;
background : #1a1a1a ;
padding : 0.5 rem 1 rem ;
border-radius : 8 px ;
}
.network-warning {
background : #ff6b6b ;
color : white ;
padding : 1 rem ;
border-radius : 8 px ;
margin-top : 1 rem ;
}
.card {
background : #1a1a1a ;
border-radius : 12 px ;
padding : 2 rem ;
margin : 1 rem 0 ;
}
.balance {
text-align : center ;
padding : 1.5 rem ;
background : #2a2a2a ;
border-radius : 8 px ;
margin : 1 rem 0 ;
}
.balance h3 {
font-size : 2 rem ;
margin : 0.5 rem 0 ;
color : #646cff ;
}
.transfer-form {
margin-top : 2 rem ;
}
.form-group {
margin-bottom : 1 rem ;
}
.form-group label {
display : block ;
margin-bottom : 0.5 rem ;
color : #888 ;
}
.form-group input {
width : 100 % ;
padding : 0.75 rem ;
border : 1 px solid #333 ;
border-radius : 6 px ;
background : #2a2a2a ;
color : white ;
font-size : 1 rem ;
}
.form-group input :focus {
outline : none ;
border-color : #646cff ;
}
button [ type = "submit" ] {
width : 100 % ;
background : #646cff ;
color : white ;
border : none ;
padding : 1 rem ;
border-radius : 8 px ;
cursor : pointer ;
font-size : 1 rem ;
margin-top : 1 rem ;
transition : background 0.3 s ;
}
button [ type = "submit" ] :hover:not ( :disabled ) {
background : #535bf2 ;
}
button [ type = "submit" ] :disabled {
background : #333 ;
cursor : not-allowed ;
}
.tx-hash {
margin-top : 1 rem ;
padding : 1 rem ;
background : #2a2a2a ;
border-radius : 6 px ;
}
.tx-hash a {
color : #646cff ;
word-break : break-all ;
}
Open http://localhost:5173 in your browser!
Click "Connect Wallet" and approve the connection in MetaMask.
If prompted, approve adding and switching to Shardeum Testnet.
Visit the Shardeum Faucet to get test SHM tokens.
Use a valid recipient address (for example, your second wallet address). If you paste an invalid address, the transaction will fail.
Change TOKEN_ADDRESS in src/config/networks.ts to your mainnet or testnet contract address as needed.
Deploy the dist folder to your hosting provider:
Vercel:
npm install -g vercel
vercel --prod
Netlify:
npm install -g netlify-cli
netlify deploy --prod --dir=dist
IPFS (Decentralized Hosting):
npm install -g ipfs-deploy
ipfs-deploy dist
Never expose private keys - Use environment variables
Validate user input - Check addresses and amounts
Handle errors gracefully - Display user-friendly messages
Test thoroughly - Use testnet before mainnet
Cache contract instances - Avoid recreating on every render
Debounce user input - For real-time balance updates
Use event listeners - Listen for blockchain events instead of polling
Optimize bundle size - Use tree-shaking and code splitting
Show loading states - Let users know transactions are processing
Display transaction links - Link to block explorer
Handle wallet disconnection - Gracefully handle user disconnecting
Mobile responsive - Test on mobile devices
if ( ! window.ethereum) {
alert ( 'Please install MetaMask!' );
window. open ( 'https://metamask.io/download/' , '_blank' );
return ;
}
if (chainId !== SHARDEUM_TESTNET .chainId) {
await switchNetwork ();
}
try {
const tx = await contract. transfer (recipient, amount);
await tx. wait ();
} catch ( error : any ) {
if (error.code === 'INSUFFICIENT_FUNDS' ) {
alert ( 'Insufficient balance' );
} else if (error.code === 'ACTION_REJECTED' ) {
alert ( 'Transaction rejected by user' );
} else {
alert ( 'Transaction failed: ' + error.message);
}
}
Now that you have a working dApp, you can:
Add more features - Token swaps, staking, NFT gallery
Improve UI/UX - Add animations, better error handling
Integrate more contracts - Connect to multiple smart contracts
Add WalletConnect - Support mobile wallets
Implement state management - Use Redux or Zustand for complex apps
Clone the complete boilerplate:
git clone https://github.com/shardeum/dapp-boilerplate.git
cd dapp-boilerplate
npm install
npm run dev
To interact with the blockchain, we first need something deployed on-chain. For this tutorial, we’ll deploy a simple ERC20 token contract.
Create a file called MyToken.sol. You will create this file after initializing the Hardhat project in the next step.
Create contracts/MyToken.sol and paste the code below:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20 ;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol" ;
contract MyToken is ERC20 {
constructor () ERC20 ("My Token", "MTK") {
_mint ( msg.sender , 1_000_000 * 10 ** decimals ());
}
}
This contract:
Mints 1 million tokens to the deployer
Supports balance checks
Supports token transfers
Initialize a Hardhat project:
mkdir shardeum-contract
cd shardeum-contract
npm init -y
npm install --save-dev hardhat
When Hardhat prompts, choose “TypeScript project”. If npx hardhat init doesn’t work on your version, run npx hardhat and choose “Create a TypeScript project” instead.
npx hardhat init
npm install --save-dev @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts
npm install dotenv
You can now create contracts/MyToken.sol and paste the contract code from Step 1.
Next, create a .env file:
PRIVATE_KEY = 0xyour_private_key_here
Update hardhat.config.ts:
import { HardhatUserConfig } from "hardhat/config" ;
import "@nomicfoundation/hardhat-toolbox" ;
import "dotenv/config" ;
const config : HardhatUserConfig = {
solidity: "0.8.20" ,
networks: {
shardeumTestnet: {
url: "https://api-mezame.shardeum.org" ,
chainId: 8119 ,
accounts: [process.env. PRIVATE_KEY ! ],
},
},
};
export default config;
Create scripts/deploy.ts:
import { ethers } from "hardhat" ;
async function main () {
const Token = await ethers. getContractFactory ( "MyToken" );
const token = await Token. deploy ();
await token. waitForDeployment ();
console. log ( "Token deployed to:" , await token. getAddress ());
}
main (). catch (( err ) => {
console. error (err);
process. exit ( 1 );
});
Deploy
npx hardhat run scripts/deploy.ts --network shardeumTestnet
Save the deployed contract address . You’ll need it in the frontend.
Now we’ll build a frontend that interacts with the deployed contract.
npm create vite@latest shardeum-dapp -- --template react-ts
cd shardeum-dapp
npm install
npm install ethers
Create src/config/networks.ts:
export const SHARDEUM_TESTNET = {
chainId: "0x1fb7" , // 8119
chainName: "Shardeum EVM Testnet" ,
nativeCurrency: {
name: "SHM" ,
symbol: "SHM" ,
decimals: 18 ,
},
rpcUrls: [ "https://api-mezame.shardeum.org" ],
blockExplorerUrls: [ "https://explorer-mezame.shardeum.org" ],
};
export const TOKEN_ADDRESS = "0xYOUR_CONTRACT_ADDRESS_HERE" ;
Create src/config/erc20Abi.ts:
export const ERC20_ABI = [
"function name() view returns (string)" ,
"function symbol() view returns (string)" ,
"function decimals() view returns (uint8)" ,
"function balanceOf(address) view returns (uint256)" ,
"function transfer(address to, uint256 amount) returns (bool)" ,
"event Transfer(address indexed from, address indexed to, uint256 value)"
];
Create src/context/WalletContext.tsx:
import { createContext, useContext, useState, useEffect, ReactNode } from 'react' ;
import { ethers, BrowserProvider, Eip1193Provider } from 'ethers' ;
import { SHARDEUM_TESTNET } from '../config/networks' ;
interface WalletContextType {
account : string | null ;
chainId : string | null ;
provider : BrowserProvider | null ;
connectWallet : () => Promise < void >;
disconnectWallet : () => void ;
switchNetwork : () => Promise < void >;
}
const WalletContext = createContext < WalletContextType | undefined >( undefined );
export function WalletProvider ({ children } : { children : ReactNode }) {
const [ account , setAccount ] = useState < string | null >( null );
const [ chainId , setChainId ] = useState < string | null >( null );
const [ provider , setProvider ] = useState < BrowserProvider | null >( null );
// Check if wallet is connected on mount
useEffect (() => {
const checkConnection = async () => {
if (window.ethereum) {
const provider = new ethers. BrowserProvider (window.ethereum as Eip1193Provider );
const accounts = await provider. send ( 'eth_accounts' , []);
if (accounts. length > 0 ) {
setAccount (accounts[ 0 ]);
setProvider (provider);
const network = await provider. getNetwork ();
setChainId ( '0x' + network.chainId. toString ( 16 ));
}
}
};
checkConnection ();
// Listen for account changes
if (window.ethereum) {
window.ethereum. on ( 'accountsChanged' , ( accounts : string []) => {
if (accounts. length > 0 ) {
setAccount (accounts[ 0 ]);
} else {
disconnectWallet ();
}
});
window.ethereum. on ( 'chainChanged' , ( chainId : string ) => {
setChainId (chainId);
window.location. reload ();
});
}
return () => {
if (window.ethereum) {
window.ethereum. removeAllListeners ( 'accountsChanged' );
window.ethereum. removeAllListeners ( 'chainChanged' );
}
};
}, []);
const connectWallet = async () => {
if ( ! window.ethereum) {
alert ( 'Please install MetaMask!' );
return ;
}
try {
const provider = new ethers. BrowserProvider (window.ethereum as Eip1193Provider );
const accounts = await provider. send ( 'eth_requestAccounts' , []);
setAccount (accounts[ 0 ]);
setProvider (provider);
const network = await provider. getNetwork ();
setChainId ( '0x' + network.chainId. toString ( 16 ));
} catch (error) {
console. error ( 'Failed to connect wallet:' , error);
}
};
const disconnectWallet = () => {
setAccount ( null );
setProvider ( null );
setChainId ( null );
};
const switchNetwork = async () => {
if ( ! window.ethereum) return ;
try {
await window.ethereum. request ({
method: 'wallet_switchEthereumChain' ,
params: [{ chainId: SHARDEUM_TESTNET .chainId }],
});
} catch ( switchError : any ) {
// This error code indicates that the chain has not been added to MetaMask
if (switchError.code === 4902 ) {
try {
await window.ethereum. request ({
method: 'wallet_addEthereumChain' ,
params: [ SHARDEUM_TESTNET ],
});
} catch (addError) {
console. error ( 'Failed to add network:' , addError);
}
} else {
console. error ( 'Failed to switch network:' , switchError);
}
}
};
return (
< WalletContext.Provider
value = {{
account,
chainId,
provider,
connectWallet,
disconnectWallet,
switchNetwork
}}
>
{ children }
</ WalletContext.Provider >
);
}
export function useWallet () {
const context = useContext (WalletContext);
if (context === undefined ) {
throw new Error ( 'useWallet must be used within a WalletProvider' );
}
return context;
}
// Extend Window type for TypeScript
declare global {
interface Window {
ethereum ?: Eip1193Provider ;
}
}
Create src/components/TokenInterface.tsx:
import { useState, useEffect } from 'react' ;
import { ethers, Contract } from 'ethers' ;
import { useWallet } from '../context/WalletContext' ;
import { TOKEN_ADDRESS } from '../config/networks' ;
import { ERC20_ABI } from '../config/erc20Abi' ;
export function TokenInterface () {
const { account , provider } = useWallet ();
const [ tokenName , setTokenName ] = useState ( '' );
const [ tokenSymbol , setTokenSymbol ] = useState ( '' );
const [ balance , setBalance ] = useState ( '0' );
const [ recipient , setRecipient ] = useState ( '' );
const [ amount , setAmount ] = useState ( '' );
const [ loading , setLoading ] = useState ( false );
const [ txHash , setTxHash ] = useState ( '' );
useEffect (() => {
if (account && provider) {
loadTokenInfo ();
loadBalance ();
}
}, [account, provider]);
const loadTokenInfo = async () => {
if ( ! provider) return ;
try {
const contract = new Contract ( TOKEN_ADDRESS , ERC20_ABI , provider);
const name = await contract. name ();
const symbol = await contract. symbol ();
setTokenName (name);
setTokenSymbol (symbol);
} catch (error) {
console. error ( 'Failed to load token info:' , error);
}
};
const loadBalance = async () => {
if ( ! provider || ! account) return ;
try {
const contract = new Contract ( TOKEN_ADDRESS , ERC20_ABI , provider);
const balance = await contract. balanceOf (account);
setBalance (ethers. formatEther (balance));
} catch (error) {
console. error ( 'Failed to load balance:' , error);
}
};
const handleTransfer = async ( e : React . FormEvent ) => {
e. preventDefault ();
if ( ! provider || ! account) return ;
setLoading ( true );
setTxHash ( '' );
try {
const signer = await provider. getSigner ();
const contract = new Contract ( TOKEN_ADDRESS , ERC20_ABI , signer);
const tx = await contract. transfer (
recipient,
ethers. parseEther (amount)
);
setTxHash (tx.hash);
await tx. wait ();
alert ( 'Transfer successful!' );
loadBalance ();
setRecipient ( '' );
setAmount ( '' );
} catch ( error : any ) {
console. error ( 'Transfer failed:' , error);
alert ( 'Transfer failed: ' + error.message);
} finally {
setLoading ( false );
}
};
if ( ! account) {
return (
< div className = "card" >
< h2 >Token Interface </ h2 >
< p >Please connect your wallet to use the token interface. </ p >
</ div >
);
}
return (
< div className = "card" >
< h2 >{tokenName} ({tokenSymbol}) </ h2 >
< div className = "balance" >
< p >Your Balance : </ p >
< h3 >{balance} { tokenSymbol } </ h3 >
</ div >
< form onSubmit = {handleTransfer} className = "transfer-form" >
< h3 >Transfer Tokens </ h3 >
< div className = "form-group" >
< label >Recipient Address : </ label >
< input
type = "text"
value = {recipient}
onChange = {(e) => setRecipient (e.target.value)}
placeholder = "0x..."
required
/>
</ div >
< div className = "form-group" >
< label > Amount : </ label >
< input
type = "number"
value = {amount}
onChange = {(e) => setAmount (e.target.value)}
placeholder = "0.0"
step = "0.000001"
required
/>
</ div >
< button type = "submit" disabled = {loading} >
{ loading ? 'Processing...' : 'Transfer' }
</ button >
{ txHash && (
< div className = "tx-hash" >
< p > Transaction Hash :</ p >
< a
href = { `https://explorer-mezame.shardeum.org/tx/${ txHash }` }
target = "_blank"
rel = "noopener noreferrer"
>
{ txHash . slice (0, 10)} ... { txHash . slice (-8)}
</ a >
</ div >
)}
</ form >
</ div >
);
}
Update src/App.tsx:
import { useWallet } from './context/WalletContext' ;
import { TokenInterface } from './components/TokenInterface' ;
import { SHARDEUM_TESTNET } from './config/networks' ;
import './App.css' ;
function App () {
const { account , chainId , connectWallet , disconnectWallet , switchNetwork } = useWallet ();
const isCorrectNetwork = chainId === SHARDEUM_TESTNET .chainId;
return (
< div className = "App" >
< header className = "App-header" >
< h1 >Shardeum dApp </ h1 >
< div className = "wallet-controls" >
{! account ? (
< button onClick = {connectWallet} className = "connect-btn" >
Connect Wallet
</ button >
) : (
< div className = "wallet-info" >
< p > Connected : { account . slice (0, 6)} ... { account . slice (-4)}</ p >
< button onClick = {disconnectWallet} className = "disconnect-btn" >
Disconnect
</ button >
</ div >
)}
</ div >
{ account && ! isCorrectNetwork && (
< div className = "network-warning" >
< p >⚠️ Wrong Network </ p >
< button onClick = {switchNetwork} > Switch to Shardeum Testnet </ button >
</ div >
)}
</ header >
< main >
{ account && isCorrectNetwork && < TokenInterface />}
</ main >
</ div >
);
}
export default App;
Update src/main.tsx:
import React from 'react' ;
import ReactDOM from 'react-dom/client' ;
import App from './App.tsx' ;
import { WalletProvider } from './context/WalletContext.tsx' ;
import './index.css' ;
ReactDOM. createRoot (document. getElementById ( 'root' ) ! ). render (
< React.StrictMode >
< WalletProvider >
< App />
</ WalletProvider >
</ React.StrictMode > ,
);
Create src/App.css:
.App {
max-width : 1200 px ;
margin : 0 auto ;
padding : 2 rem ;
}
.App-header {
text-align : center ;
margin-bottom : 2 rem ;
}
.wallet-controls {
margin-top : 1 rem ;
}
.connect-btn ,
.disconnect-btn {
background : #646cff ;
color : white ;
border : none ;
padding : 0.75 rem 1.5 rem ;
border-radius : 8 px ;
cursor : pointer ;
font-size : 1 rem ;
transition : background 0.3 s ;
}
.connect-btn:hover ,
.disconnect-btn:hover {
background : #535bf2 ;
}
.wallet-info {
display : inline-flex ;
align-items : center ;
gap : 1 rem ;
background : #1a1a1a ;
padding : 0.5 rem 1 rem ;
border-radius : 8 px ;
}
.network-warning {
background : #ff6b6b ;
color : white ;
padding : 1 rem ;
border-radius : 8 px ;
margin-top : 1 rem ;
}
.card {
background : #1a1a1a ;
border-radius : 12 px ;
padding : 2 rem ;
margin : 1 rem 0 ;
}
.balance {
text-align : center ;
padding : 1.5 rem ;
background : #2a2a2a ;
border-radius : 8 px ;
margin : 1 rem 0 ;
}
.balance h3 {
font-size : 2 rem ;
margin : 0.5 rem 0 ;
color : #646cff ;
}
.transfer-form {
margin-top : 2 rem ;
}
.form-group {
margin-bottom : 1 rem ;
}
.form-group label {
display : block ;
margin-bottom : 0.5 rem ;
color : #888 ;
}
.form-group input {
width : 100 % ;
padding : 0.75 rem ;
border : 1 px solid #333 ;
border-radius : 6 px ;
background : #2a2a2a ;
color : white ;
font-size : 1 rem ;
}
.form-group input :focus {
outline : none ;
border-color : #646cff ;
}
button [ type = "submit" ] {
width : 100 % ;
background : #646cff ;
color : white ;
border : none ;
padding : 1 rem ;
border-radius : 8 px ;
cursor : pointer ;
font-size : 1 rem ;
margin-top : 1 rem ;
transition : background 0.3 s ;
}
button [ type = "submit" ] :hover:not ( :disabled ) {
background : #535bf2 ;
}
button [ type = "submit" ] :disabled {
background : #333 ;
cursor : not-allowed ;
}
.tx-hash {
margin-top : 1 rem ;
padding : 1 rem ;
background : #2a2a2a ;
border-radius : 6 px ;
}
.tx-hash a {
color : #646cff ;
word-break : break-all ;
}
Open http://localhost:5173 in your browser!
Click "Connect Wallet" and approve the connection in MetaMask.
If prompted, approve adding and switching to Shardeum Testnet.
Visit the Shardeum Faucet to get test SHM tokens.
Use a valid recipient address (for example, your second wallet address). If you paste an invalid address, the transaction will fail.
Change TOKEN_ADDRESS in src/config/networks.ts to your mainnet or testnet contract address as needed.
Deploy the dist folder to your hosting provider:
Vercel:
npm install -g vercel
vercel --prod
Netlify:
npm install -g netlify-cli
netlify deploy --prod --dir=dist
IPFS (Decentralized Hosting):
npm install -g ipfs-deploy
ipfs-deploy dist
Never expose private keys - Use environment variables
Validate user input - Check addresses and amounts
Handle errors gracefully - Display user-friendly messages
Test thoroughly - Use testnet before mainnet
Cache contract instances - Avoid recreating on every render
Debounce user input - For real-time balance updates
Use event listeners - Listen for blockchain events instead of polling
Optimize bundle size - Use tree-shaking and code splitting
Show loading states - Let users know transactions are processing
Display transaction links - Link to block explorer
Handle wallet disconnection - Gracefully handle user disconnecting
Mobile responsive - Test on mobile devices
if ( ! window.ethereum) {
alert ( 'Please install MetaMask!' );
window. open ( 'https://metamask.io/download/' , '_blank' );
return ;
}
if (chainId !== SHARDEUM_TESTNET .chainId) {
await switchNetwork ();
}
try {
const tx = await contract. transfer (recipient, amount);
await tx. wait ();
} catch ( error : any ) {
if (error.code === 'INSUFFICIENT_FUNDS' ) {
alert ( 'Insufficient balance' );
} else if (error.code === 'ACTION_REJECTED' ) {
alert ( 'Transaction rejected by user' );
} else {
alert ( 'Transaction failed: ' + error.message);
}
}
Now that you have a working dApp, you can:
Add more features - Token swaps, staking, NFT gallery
Improve UI/UX - Add animations, better error handling
Integrate more contracts - Connect to multiple smart contracts
Add WalletConnect - Support mobile wallets
Implement state management - Use Redux or Zustand for complex apps
Clone the complete boilerplate:
git clone https://github.com/shardeum/dapp-boilerplate.git
cd dapp-boilerplate
npm install
npm run dev