Build a Frontend dApp Testing First: For development, begin by building and testing on the Shardeum EVM Testnet before deploying to mainnet.
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. Follow all frontend steps in order to avoid duplication. The tutorial does not assume prior deployments. Using this tutorial, 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
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
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" ;
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol" ;
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol" ;
import "@openzeppelin/contracts/access/Ownable.sol" ;
/**
* @title MyToken
* @dev Implementation of a basic ERC20 token with additional safety features
* Uses OpenZeppelin's secure and tested contracts
*/
contract MyToken is ERC20 , ERC20Burnable , ERC20Permit , Ownable {
/**
* @dev Constructor that mints initial supply to the deployer
* @notice Creates 1,000,000 tokens with 18 decimals
*/
constructor ()
ERC20 ("My Token", "MTK")
ERC20Permit ("My Token")
Ownable (msg.sender)
{
_mint ( msg.sender , 1_000_000 * 10 ** decimals ());
}
/**
* @dev Allows owner to mint additional tokens if needed
* @param to Address to receive the minted tokens
* @param amount Amount of tokens to mint (in wei)
*/
function mint ( address to , uint256 amount ) public onlyOwner {
_mint (to, amount);
}
}
This contract:
Mints 1 million tokens to the deployer with 18 decimals
Supports balance checks and token transfers
Implements ERC20Burnable for token burning capability
Implements ERC20Permit for gasless approvals
Uses Ownable for access control
Includes a mint function for the owner to create additional tokens
Initialize a Hardhat project:
mkdir shardeum-contract
cd shardeum-contract
npm init -y
npm install --save-dev hardhat
When Hardhat prompts, choose “Create a TypeScript project.” If npx hardhat init doesn’t run automatically, execute npx hardhat and select TypeScript project from the menu.
npx hardhat
# Choose: "Create a TypeScript project"
npm install --save-dev @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts
npm install dotenv
After initialization, your folder structure should look like this:
shardeum-contract => contracts/ => scripts/ => hardhat.config.ts => package.json => node_modules/
Create a .env file (Hardhat project only). Create this .env inside shardeum-contract/ (same level as hardhat.config.ts).
PRIVATE_KEY = 0xYOUR_PRIVATE_KEY_HERE
Security note :
This .env file is used only for the Hardhat project to deploy contracts.
Never paste this private key into source code, GitHub, or screenshots.
Use a development wallet only - never your main wallet.
Protect your private key from Git
Create a .gitignore file in the same shardeum-contract folder with the following content. This ensures your private key is never committed to version control.
node_modules
.env
.env.*
artifacts
cache
Update hardhat.config.ts:
import { HardhatUserConfig } from "hardhat/config" ;
import "@nomicfoundation/hardhat-toolbox" ;
import "dotenv/config" ;
// Normalize private key to ensure it has 0x prefix
function getAccounts () : string [] {
if ( ! process.env. PRIVATE_KEY ) {
console. warn (
"⚠️ WARNING: No PRIVATE_KEY found in .env file! \n " +
"Deployment will fail. Please add your private key to .env: \n " +
"PRIVATE_KEY=your_private_key_here"
);
return [];
}
const key = process.env. PRIVATE_KEY . trim ();
const normalizedKey = key. startsWith ( '0x' ) ? key : `0x${ key }` ;
return [normalizedKey];
}
const config : HardhatUserConfig = {
solidity: "0.8.20" ,
networks: {
shardeumTestnet: {
url: "https://api-mezame.shardeum.org" ,
chainId: 8119 ,
accounts: getAccounts (),
},
},
};
export default config;
Tip: This config automatically handles private keys with or without the 0x prefix, and provides clear warnings if the key is missing.
You can now create contracts/MyToken.sol and paste the contract code from Step 1.
Create scripts/deploy.ts:
import { ethers } from "hardhat" ;
import fs from "fs" ;
import path from "path" ;
async function main () {
console. log ( "Deploying MyToken contract..." );
const Token = await ethers. getContractFactory ( "MyToken" );
const token = await Token. deploy ();
await token. waitForDeployment ();
const address = await token. getAddress ();
console. log ( "✅ Token deployed to:" , address);
// Write the deployed address to the frontend config
const configPath = path. join (__dirname, "../src/config/networks.ts" );
const configContent = `import { ethers } from 'ethers';
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 = "${ address }";
// Runtime validation for TOKEN_ADDRESS
if (TOKEN_ADDRESS === "0xYOUR_CONTRACT_ADDRESS_HERE" || !ethers.isAddress(TOKEN_ADDRESS)) {
console.warn(
"⚠️ WARNING: TOKEN_ADDRESS is not configured! \\ n" +
"Please update TOKEN_ADDRESS in src/config/networks.ts with your deployed contract address. \\ n" +
"Deploy your contract with: npm run deploy:testnet"
);
}
` ;
fs. writeFileSync (configPath, configContent);
console. log ( "✅ Contract address written to src/config/networks.ts" );
console. log ( " \n Next steps:" );
console. log ( "1. Run 'npm run dev' to start the frontend" );
console. log ( "2. Connect your wallet and interact with your token!" );
console. log ( ` \n View your contract on the explorer: https://explorer-mezame.shardeum.org/address/${ address }` );
}
main (). catch (( err ) => {
console. error (err);
process. exit ( 1 );
});
Deploy:
npx hardhat run scripts/deploy.ts --network shardeumTestnet
Or add a convenient script to your package.json:
{
"scripts" : {
"deploy:testnet" : "npx hardhat run scripts/deploy.ts --network shardeumTestnet"
}
}
Then deploy with:
✨ Note: The deploy script automatically updates src/config/networks.ts with your contract address, so you don't need to manually copy it!
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
Package Version Management : For production applications, it's recommended to pin exact versions in your package.json by removing the ^ prefix from dependencies. This ensures reproducible builds. Example: use "ethers": "6.10.0" instead of "ethers": "^6.10.0".
Create src/config/networks.ts:
import { ethers } from 'ethers' ;
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" ;
// Runtime validation for TOKEN_ADDRESS
if ( TOKEN_ADDRESS === "0xYOUR_CONTRACT_ADDRESS_HERE" || ! ethers. isAddress ( TOKEN_ADDRESS )) {
console. warn (
"⚠️ WARNING: TOKEN_ADDRESS is not configured! \n " +
"Please update TOKEN_ADDRESS in src/config/networks.ts with your deployed contract address. \n " +
"Deploy your contract with: npm run deploy:testnet"
);
}
Important : Replace TOKEN_ADDRESS with your deployed contract address. If you forget this step, you'll see a warning in the console, and contract reads/writes will fail.
Create src/config/erc20Abi.ts:
/**
* Complete ERC20 ABI with additional OpenZeppelin extensions
* Includes: ERC20, ERC20Burnable, ERC20Permit, and Ownable functions
*/
export const ERC20_ABI = [
// Standard ERC20 Functions
"function name() view returns (string)" ,
"function symbol() view returns (string)" ,
"function decimals() view returns (uint8)" ,
"function totalSupply() view returns (uint256)" ,
"function balanceOf(address account) view returns (uint256)" ,
"function transfer(address to, uint256 amount) returns (bool)" ,
"function allowance(address owner, address spender) view returns (uint256)" ,
"function approve(address spender, uint256 amount) returns (bool)" ,
"function transferFrom(address from, address to, uint256 amount) returns (bool)" ,
// ERC20Burnable Functions
"function burn(uint256 amount)" ,
"function burnFrom(address account, uint256 amount)" ,
// Ownable Functions
"function owner() view returns (address)" ,
"function renounceOwnership()" ,
"function transferOwnership(address newOwner)" ,
// Custom Functions (from MyToken contract)
"function mint(address to, uint256 amount)" ,
// ERC20Permit Functions (EIP-2612 - Gasless Approvals)
"function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)" ,
"function nonces(address owner) view returns (uint256)" ,
"function DOMAIN_SEPARATOR() view returns (bytes32)" ,
// Events
"event Transfer(address indexed from, address indexed to, uint256 value)" ,
"event Approval(address indexed owner, address indexed spender, uint256 value)" ,
"event OwnershipTransferred(address indexed previousOwner, address indexed newOwner)"
];
This complete ABI includes all functions from the enhanced MyToken contract, allowing you to interact with burning, minting, and ownership features in the future.
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 ;
error : string | null ;
connectWallet : () => Promise < void >;
disconnectWallet : () => Promise < void >;
switchNetwork : () => Promise < void >;
clearError : () => 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 );
const [ error , setError ] = useState < string | 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 ));
}
}
};
// Store handler references for proper cleanup
const handleAccountsChanged = ( accounts : string []) => {
if (accounts. length > 0 ) {
setAccount (accounts[ 0 ]);
} else {
disconnectWallet ();
}
};
const handleChainChanged = async ( newChainId : string ) => {
setChainId (newChainId);
// Re-initialize provider instead of full page reload
if (window.ethereum) {
try {
const newProvider = new ethers. BrowserProvider (window.ethereum as Eip1193Provider );
setProvider (newProvider);
const accounts = await newProvider. send ( 'eth_accounts' , []);
if (accounts. length > 0 ) {
setAccount (accounts[ 0 ]);
}
} catch (err) {
console. error ( 'Failed to reinitialize provider:' , err);
}
}
};
checkConnection ();
// Listen for account and chain changes
if (window.ethereum) {
window.ethereum. on ( 'accountsChanged' , handleAccountsChanged);
window.ethereum. on ( 'chainChanged' , handleChainChanged);
}
return () => {
if (window.ethereum) {
window.ethereum. removeListener ( 'accountsChanged' , handleAccountsChanged);
window.ethereum. removeListener ( 'chainChanged' , handleChainChanged);
}
};
}, []);
const connectWallet = async () => {
if ( ! window.ethereum) {
setError ( 'Please install MetaMask to use this dApp!' );
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 ));
setError ( null );
} catch ( err : any ) {
console. error ( 'Failed to connect wallet:' , err);
setError (err.message || 'Failed to connect wallet' );
}
};
const disconnectWallet = async () => {
// Try to properly revoke permissions (MetaMask 11.0+)
try {
await window.ethereum?. request ({
method: 'wallet_revokePermissions' ,
params: [{ eth_accounts: {} }],
});
} catch (err) {
// Fallback for wallets that don't support revokePermissions
// Note: This only clears local state. MetaMask will still consider the site connected
// and will auto-reconnect on page reload. This is a MetaMask limitation.
console. log ( 'wallet_revokePermissions not supported, clearing local state only' );
}
setAccount ( null );
setProvider ( null );
setChainId ( null );
setError ( null );
};
const switchNetwork = async () => {
if ( ! window.ethereum) return ;
try {
await window.ethereum. request ({
method: 'wallet_switchEthereumChain' ,
params: [{ chainId: SHARDEUM_TESTNET .chainId }],
});
setError ( null );
} 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 ],
});
setError ( null );
} catch ( addError : any ) {
console. error ( 'Failed to add network:' , addError);
setError (addError.message || 'Failed to add network' );
}
} else {
console. error ( 'Failed to switch network:' , switchError);
setError (switchError.message || 'Failed to switch network' );
}
}
};
const clearError = () => {
setError ( null );
};
return (
< WalletContext.Provider
value = {{
account,
chainId,
provider,
error,
connectWallet,
disconnectWallet,
switchNetwork,
clearError
}}
>
{ 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 with proper event listener support
declare global {
interface Window {
ethereum ?: Eip1193Provider & {
on ( event : string , handler : ( ... args : any []) => void ) : void ;
removeListener ( event : string , handler : ( ... args : any []) => void ) : void ;
removeAllListeners ( event : string ) : void ;
};
}
}
Key improvements in this context:
Error handling : Added error state to display connection issues to users
Proper disconnect : Uses wallet_revokePermissions when available
TypeScript safety : Proper typing for window.ethereum event methods
No page reload : Reinitializes provider on chain change instead of full refresh
Cleanup : Proper event listener removal to avoid memory leaks
Create src/components/TokenInterface.tsx:
import { useState, useEffect, useCallback } 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 [ loadingTokenInfo , setLoadingTokenInfo ] = useState ( true );
const [ txHash , setTxHash ] = useState ( '' );
const [ error , setError ] = useState < string | null >( null );
const [ success , setSuccess ] = useState < string | null >( null );
const loadTokenInfo = useCallback ( async () => {
if ( ! provider) return ;
setLoadingTokenInfo ( true );
setError ( null );
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 ( err : any ) {
console. error ( 'Failed to load token info:' , err);
setError (
'Failed to load token information. Please ensure the contract is deployed and you are on the correct network.'
);
} finally {
setLoadingTokenInfo ( false );
}
}, [provider]);
const loadBalance = useCallback ( 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 ( err : any ) {
console. error ( 'Failed to load balance:' , err);
setError ( 'Failed to load balance. Please check your connection.' );
}
}, [provider, account]);
useEffect (() => {
if (account && provider) {
loadTokenInfo ();
loadBalance ();
}
}, [account, provider, loadTokenInfo, loadBalance]);
const validateTransfer = () : string | null => {
// Validate recipient address
if ( ! ethers. isAddress (recipient)) {
return 'Invalid recipient address' ;
}
// Validate amount is a positive number
const parsedAmount = parseFloat (amount);
if ( isNaN (parsedAmount) || parsedAmount <= 0 ) {
return 'Amount must be greater than 0' ;
}
// Validate sufficient balance
if (parsedAmount > parseFloat (balance)) {
return 'Insufficient balance' ;
}
return null ;
};
const handleTransfer = async ( e : React . FormEvent ) => {
e. preventDefault ();
if ( ! provider || ! account) return ;
// Clear previous messages
setError ( null );
setSuccess ( null );
setTxHash ( '' );
// Validate inputs
const validationError = validateTransfer ();
if (validationError) {
setError (validationError);
return ;
}
setLoading ( true );
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);
setSuccess ( 'Transaction submitted! Waiting for confirmation...' );
await tx. wait ();
setSuccess ( `Successfully transferred ${ amount } ${ tokenSymbol } to ${ recipient . slice ( 0 , 6 ) }...${ recipient . slice ( - 4 ) }` );
loadBalance ();
setRecipient ( '' );
setAmount ( '' );
} catch ( err : any ) {
console. error ( 'Transfer failed:' , err);
// Parse common error messages
let errorMessage = 'Transfer failed' ;
if (err.code === 'ACTION_REJECTED' ) {
errorMessage = 'Transaction was rejected by user' ;
} else if (err.message. includes ( 'insufficient funds' )) {
errorMessage = 'Insufficient funds for gas' ;
} else if (err.message) {
errorMessage = `Transfer failed: ${ err . message }` ;
}
setError (errorMessage);
setTxHash ( '' );
} 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 >
);
}
if (loadingTokenInfo) {
return (
< div className = "card" >
< h2 >Token Interface </ h2 >
< p >Loading token information ...</ p >
</ div >
);
}
if (error && ! tokenName) {
return (
< div className = "card" >
< h2 >Token Interface </ h2 >
< div className = "error-box" >
< p >❌ { error } </ p >
< button onClick = {loadTokenInfo} className = "retry-btn" >
Retry
</ button >
</ div >
</ div >
);
}
return (
< div className = "card" >
< h2 >{tokenName || 'Token' } ({tokenSymbol || '---' }) </ h2 >
< div className = "balance" >
< p >Your Balance : </ p >
< h3 >{balance} { tokenSymbol } </ h3 >
</ div >
{ error && (
< div className = "error-box" >
< p >❌ { error }</ p >
< button onClick = {() => setError ( null )} className = "close-btn" > ✕ </ button >
</ div >
)}
{ success && (
< div className = "success-box" >
< p >✅ { success }</ p >
< button onClick = {() => setSuccess ( null )} className = "close-btn" > ✕ </ button >
</ 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?. toLowerCase () === SHARDEUM_TESTNET .chainId. toLowerCase ();
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 ;
width : 100 % ;
margin : 0 auto ;
padding : 1 rem ;
}
@media ( min-width : 768 px ) {
.App {
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 : 1 rem auto ;
max-width : 600 px ;
text-align : center ;
}
.network-warning button {
margin-top : 0.5 rem ;
background : white ;
color : #ff6b6b ;
font-weight : 600 ;
}
.network-warning button :hover {
background : #f0f0f0 ;
border-color : white ;
}
.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 % ;
max-width : 100 % ;
padding : 0.75 rem ;
border : 1 px solid #333 ;
border-radius : 6 px ;
background : #2a2a2a ;
color : white ;
font-size : 1 rem ;
box-sizing : border-box ;
}
.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
Security Disclaimer : The code in this tutorial is a reference boilerplate only. Security audits, comprehensive testing, and safe deployment practices are the responsibility of the user/developer.
Never expose private keys - Use environment variables and never commit .env files
Validate user input - Check addresses and amounts before transactions (as shown in the TokenInterface component)
Handle errors gracefully - Display user-friendly messages (implemented with error/success states)
Test thoroughly - Always test on testnet before mainnet deployment
Run security analysis tools - Use SAST tools to identify vulnerabilities
Understanding your smart contract's security posture is critical before deployment. Use industry-standard SAST (Static Application Security Testing) tools:
Slither - Static Analysis Framework
Slither is a Solidity static analysis framework that detects vulnerabilities and suggests improvements.
Installation:
pip3 install slither-analyzer
Run analysis:
Slither provides:
Vulnerability detection (reentrancy, integer overflow, etc.)
Code quality suggestions
Gas optimization recommendations
Best practice violations
Aderyn - Rust-based Auditor
Aderyn is a fast, Rust-based static analyzer for Solidity contracts.
Installation:
cargo install aderyn
# Or: cargo binstall aderyn
Run analysis:
Generate detailed report:
aderyn . --output report.md
Security Checklist
Before deploying to production:
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
Understand wallet disconnect - Disconnect clears your app state; MetaMask may still show the site as connected until the user disconnects from MetaMask itself.
if ( ! window.ethereum) {
alert ( 'Please install MetaMask!' );
window. open ( 'https://metamask.io/download/' , '_blank' );
return ;
}
if (chainId?. toLowerCase () !== SHARDEUM_TESTNET .chainId. toLowerCase ()) {
await switchNetwork ();
}
try {
const tx = await contract. transfer (recipient, ethers. parseUnits (amount, decimals));
await tx. wait ();
} catch ( error : any ) {
if (error.code === 'ACTION_REJECTED' ) {
alert ( 'Transaction rejected in wallet.' );
} else {
alert (error?.message || 'Transaction failed.' );
}
}
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
Want to skip the setup? Clone the complete boilerplate with everything pre-configured for Shardeum EVM Testnet:
git clone https://github.com/shardeum/shardeum-dapp-boilerplate.git
cd shardeum-dapp-boilerplate
npm install
Configure your environment:
cp .env.example .env
# Edit .env and add your PRIVATE_KEY
Deploy contracts and run:
# Deploy to Shardeum Testnet
npx hardhat run scripts/deploy.js --network shardeumTestnet
# Start the frontend
npm run dev
The boilerplate includes:
✅ Pre-configured network settings for Shardeum EVM Testnet (Chain ID: 8119)
✅ Wallet connection with MetaMask
✅ Sample contracts (ERC20 tokens, NFT minting, Uniswap integration)
✅ Complete frontend with Next.js and ethers.js
✅ Deployment scripts ready to use