Shardeum Documentation

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

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

Prerequisites

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

Part 1: Deploy a Sample Smart Contract

To interact with the blockchain, we first need something deployed on-chain. For this tutorial, we’ll deploy a simple ERC20 token contract.

1. Create the 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

2. Deploy the Contract with Hardhat

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("\nNext 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(`\nView 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:

npm run deploy:testnet

✨ Note: The deploy script automatically updates src/config/networks.ts with your contract address, so you don't need to manually copy it!

Part 2: Build the Frontend dApp

Now we’ll build a frontend that interacts with the deployed contract.

1. Create a React App

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".

2. Configure the Shardeum Network

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.

3. Contract ABI

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.

4: Create Wallet Context

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

5: Create Token Interface Component

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

7: Create Main App Component

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;

8: Update Main Entry Point

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

9: Add Styling

Create src/App.css:

.App {
  max-width: 1200px;
  width: 100%;
  margin: 0 auto;
  padding: 1rem;
}
 
@media (min-width: 768px) {
  .App {
    padding: 2rem;
  }
}
 
.App-header {
  text-align: center;
  margin-bottom: 2rem;
}
 
.wallet-controls {
  margin-top: 1rem;
}
 
.connect-btn,
.disconnect-btn {
  background: #646cff;
  color: white;
  border: none;
  padding: 0.75rem 1.5rem;
  border-radius: 8px;
  cursor: pointer;
  font-size: 1rem;
  transition: background 0.3s;
}
 
.connect-btn:hover,
.disconnect-btn:hover {
  background: #535bf2;
}
 
.wallet-info {
  display: inline-flex;
  align-items: center;
  gap: 1rem;
  background: #1a1a1a;
  padding: 0.5rem 1rem;
  border-radius: 8px;
}
 
.network-warning {
  background: #ff6b6b;
  color: white;
  padding: 1rem;
  border-radius: 8px;
  margin: 1rem auto;
  max-width: 600px;
  text-align: center;
}
 
.network-warning button {
  margin-top: 0.5rem;
  background: white;
  color: #ff6b6b;
  font-weight: 600;
}
 
.network-warning button:hover {
  background: #f0f0f0;
  border-color: white;
}
 
.card {
  background: #1a1a1a;
  border-radius: 12px;
  padding: 2rem;
  margin: 1rem 0;
}
 
.balance {
  text-align: center;
  padding: 1.5rem;
  background: #2a2a2a;
  border-radius: 8px;
  margin: 1rem 0;
}
 
.balance h3 {
  font-size: 2rem;
  margin: 0.5rem 0;
  color: #646cff;
}
 
.transfer-form {
  margin-top: 2rem;
}
 
.form-group {
  margin-bottom: 1rem;
}
 
.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  color: #888;
}
 
.form-group input {
  width: 100%;
  max-width: 100%;
  padding: 0.75rem;
  border: 1px solid #333;
  border-radius: 6px;
  background: #2a2a2a;
  color: white;
  font-size: 1rem;
  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: 1rem;
  border-radius: 8px;
  cursor: pointer;
  font-size: 1rem;
  margin-top: 1rem;
  transition: background 0.3s;
}
 
button[type="submit"]:hover:not(:disabled) {
  background: #535bf2;
}
 
button[type="submit"]:disabled {
  background: #333;
  cursor: not-allowed;
}
 
.tx-hash {
  margin-top: 1rem;
  padding: 1rem;
  background: #2a2a2a;
  border-radius: 6px;
}
 
.tx-hash a {
  color: #646cff;
  word-break: break-all;
}

10: Run the dApp

npm run dev

Open http://localhost:5173 in your browser!

Part 3: Testing Your dApp

1. Connect MetaMask

Click "Connect Wallet" and approve the connection in MetaMask.

2. Switch to Shardeum Testnet

If prompted, approve adding and switching to Shardeum Testnet.

3. Get Test Tokens

Visit the Shardeum Faucet to get test SHM tokens.

4. Transfer Tokens

Use a valid recipient address (for example, your second wallet address). If you paste an invalid address, the transaction will fail.

Part 4: Production Deployment

1. Update Configuration

Change TOKEN_ADDRESS in src/config/networks.ts to your mainnet or testnet contract address as needed.

2. Build for Production

npm run build

3. Deploy

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

Best Practices

Security

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.

  1. Never expose private keys - Use environment variables and never commit .env files
  2. Validate user input - Check addresses and amounts before transactions (as shown in the TokenInterface component)
  3. Handle errors gracefully - Display user-friendly messages (implemented with error/success states)
  4. Test thoroughly - Always test on testnet before mainnet deployment
  5. Run security analysis tools - Use SAST tools to identify vulnerabilities

Security Testing with SAST Tools

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 .

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:

aderyn .

Generate detailed report:

aderyn . --output report.md

Security Checklist

Before deploying to production:

  • Run slither . and address all findings
  • Run aderyn . and review the report
  • Test all functions on testnet
  • Verify access control (onlyOwner functions)
  • Check for reentrancy vulnerabilities
  • Validate input parameters
  • Review token economics (mint, burn, supply)
  • Ensure private keys are secure
  • Consider professional security audit for production applications

Performance

  1. Debounce user input - For real-time balance updates
  2. Use event listeners - Listen for blockchain events instead of polling
  3. Optimize bundle size - Use tree-shaking and code splitting

User Experience

  1. Show loading states - Let users know transactions are processing
  2. Display transaction links - Link to block explorer
  3. Handle wallet disconnection - Gracefully handle user disconnecting
  4. Mobile responsive - Test on mobile devices
  5. Understand wallet disconnect - Disconnect clears your app state; MetaMask may still show the site as connected until the user disconnects from MetaMask itself.

Common Issues

MetaMask Not Detected

if (!window.ethereum) {
  alert('Please install MetaMask!');
  window.open('https://metamask.io/download/', '_blank');
  return;
}

Wrong Network

if (chainId?.toLowerCase() !== SHARDEUM_TESTNET.chainId.toLowerCase()) {
  await switchNetwork();
}

Transaction Failed

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.');
  }
}

Next Steps

Now that you have a working dApp, you can:

  1. Add more features - Token swaps, staking, NFT gallery
  2. Improve UI/UX - Add animations, better error handling
  3. Integrate more contracts - Connect to multiple smart contracts
  4. Add WalletConnect - Support mobile wallets
  5. Implement state management - Use Redux or Zustand for complex apps

Additional Resources

Full Boilerplate Repository

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

Need Help? Join the Shardeum Discord for community support!