Deploy a DAO Governance Token on L2

Deploy an ERC20Votes governance token on Arbitrum and build a delegation UI.

Introduction

30 Second Summary

When hundreds of people need to make decisions together, they vote. In a DAO (Decentralized Autonomous Organization), those votes happen on-chain. Every token holder gets a say, and the blockchain keeps the tally transparent and tamper-proof.

In this project, you will deploy a governance token for a fictional developer collective called "BuilderDAO" on Arbitrum's L2 testnet and build a React dashboard where members connect their wallets, check their token balance, and delegate their voting power.

What You'll Build

You can demo a live governance token on a real L2 testnet, connect your MetaMask wallet, see your BLDAO token balance, and experience the moment your voting power jumps from zero to full after you self-delegate.

By the end of this project, you'll have:

  • A live ERC-20 governance token on Arbitrum Sepolia where you hold 1,000,000 BLDAO tokens and can delegate voting power to any address.
  • A React dashboard powered by wagmi and viem that connects your wallet, reads on-chain token data in real time, and submits delegation transactions with confirmation feedback.
  • The firsthand "aha" moment where your voting power shows 0 despite holding tokens, and you fix it by calling delegate. This is the core mechanic behind Uniswap, Compound, and Arbitrum DAO governance.
  • Secret Mission: Store your DAO's charter document on IPFS and reference its content hash on-chain.

Are there any prerequisites?

This project assumes you have basic Solidity and smart contract knowledge, plus familiarity with React and TypeScript.

You also need Node.js 18+ with npm installed, and the MetaMask browser extension set up with a wallet.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Every on-chain DAO faces the same challenge: voting costs gas, and gas on Ethereum L1 makes participation expensive. Take a moment to think about the governance problem you're solving with this token and why deploying on an L2 matters for a DAO with hundreds of members voting on-chain.

Set Up the Hardhat 3 Project with OpenZeppelin

To deploy a governance token on Arbitrum, you first need a Solidity development environment that can compile contracts and talk to the Arbitrum Sepolia testnet.

In this step, you will set up Hardhat 3 with the Viem toolbox, install OpenZeppelin Contracts for battle-tested token standards, and write the BuilderDAO governance token contract.

In this step, get ready to:
  • Initialize a Hardhat 3 project with the Viem toolbox template and install OpenZeppelin Contracts.
  • Configure the Arbitrum Sepolia network in your Hardhat config.
  • Create the BuilderDAOToken contract with the ERC20Votes pattern and compile it.
Initialize the Hardhat 3 project and install OpenZeppelin

Hardhat 3 provides an interactive init command that scaffolds a full TypeScript project with the Viem toolbox already configured.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir builderdao-token
cd builderdao-token
  • Initialize the Hardhat 3 project by running:
npx hardhat --init

What will the init command ask?

The command prompts you to select a project type. Choose A TypeScript Hardhat project using Node Test Runner and Viem.

Accept the default answers for all other prompts. Hardhat will install all required dependencies automatically.

  • Confirm the project was created by running:
ls

You should see hardhat.config.ts, a contracts folder, a test folder, and an ignition folder in the output.

Command not found or install failed?

Make sure you have a recent version of Node.js installed. Run node --version to check.

If npx hardhat --init hangs or fails, delete the folder, recreate it, and try again with a stable internet connection.

Still stuck? Help me troubleshoot the Hardhat init command

  • Install OpenZeppelin Contracts by running:
npm install @openzeppelin/contracts

This adds OpenZeppelin's audited smart contract library to your project. You will use their ERC20, ERC20Votes, ERC20Permit, Nonces, and Ownable contracts.

Install failed with permission errors?

Run the command without sudo. If you see EACCES errors, your npm global directory has a permissions issue. Fix it by following npm's official guide on fixing permissions, or use a Node version manager.

Help me fix npm permission errors

Configure the Arbitrum Sepolia network

The generated hardhat.config.ts only includes a local network by default. You need to add Arbitrum Sepolia so Hardhat knows where to deploy your contract later.

  • Open hardhat.config.ts in your code editor.
  • Replace the entire file contents with the following code:
import hardhatToolboxViem from "@nomicfoundation/hardhat-toolbox-viem";
import { defineConfig, configVariable } from "hardhat/config";

export default defineConfig({
  plugins: [hardhatToolboxViem],
  solidity: {
    version: "0.8.28",
  },
  networks: {
    arbitrumSepolia: {
      type: "http",
      url: "https://sepolia-rollup.arbitrum.io/rpc",
      chainId: 421614,
      accounts: [configVariable("DEPLOYER_PRIVATE_KEY")],
    },
  },
});

What does this config do?

  • plugins: [hardhatToolboxViem] loads the Viem toolbox bundle (Ignition, Viem, test runner, verification).
  • solidity.version: "0.8.28" pins the Solidity compiler version for reproducible builds.
  • networks.arbitrumSepolia defines the L2 testnet connection. type: "http" tells Hardhat this is a remote JSON-RPC endpoint.
  • configVariable("DEPLOYER_PRIVATE_KEY") reads your wallet key at deploy time without hardcoding it in the file.
  • Save hardhat.config.ts.
Create the BuilderDAOToken contract and compile

The token contract inherits from five OpenZeppelin bases. Together they give you an ERC-20 token with gasless approval (Permit), historical voting power tracking (Votes), and access control (Ownable).

  • Delete the sample contracts that Hardhat generated by running:
rm contracts/Counter.sol contracts/Counter.t.sol
  • Create a new file called contracts/BuilderDAOToken.sol in your code editor and paste the following imports:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

What are these imports?

  • ERC20 is the base fungible token standard (balance tracking, transfers).
  • ERC20Permit adds gasless approvals via EIP-2612 signatures.
  • ERC20Votes tracks historical voting power using checkpoints. This is the core governance primitive.
  • Nonces is required because both Permit and Votes use nonces internally. You must override to resolve the conflict.
  • Ownable restricts sensitive functions (like setting the charter CID) to the contract deployer.
  • Below the imports, add the contract declaration and constructor:
contract BuilderDAOToken is ERC20, ERC20Permit, ERC20Votes, Ownable {
    string public charterCID;

    constructor(address initialOwner)
        ERC20("BuilderDAO", "BLDAO")
        ERC20Permit("BuilderDAO")
        Ownable(initialOwner)
    {
        _mint(initialOwner, 1_000_000 * 10 ** decimals());
    }

    function setCharterCID(string calldata cid) external onlyOwner {
        charterCID = cid;
    }

What does the constructor do?

The constructor sets the token name to "BuilderDAO" with symbol "BLDAO". It mints 1,000,000 tokens (adjusted for 18 decimals) to the initialOwner address passed at deploy time.

The charterCID state variable stores an IPFS content hash. You will use it in the Secret Mission to reference the DAO's charter document on-chain.

  • Below the setCharterCID function, add the required overrides to complete the contract:
    // Overrides required by Solidity for multiple inheritance.

    function _update(address from, address to, uint256 amount)
        internal
        override(ERC20, ERC20Votes)
    {
        super._update(from, to, amount);
    }

    function nonces(address owner)
        public
        view
        override(ERC20Permit, Nonces)
        returns (uint256)
    {
        return super.nonces(owner);
    }
}

Why are the overrides needed?

Solidity requires you to explicitly resolve conflicts when multiple parent contracts define the same function. Both ERC20 and ERC20Votes define _update, so you override it and call super._update to ensure both run.

Similarly, ERC20Permit and Nonces both define nonces. The override resolves the diamond by delegating to super.nonces.

  • Save contracts/BuilderDAOToken.sol.

Before you compile, what do you think will happen? Will the contract compile on the first try, or will Solidity complain about something?

  • Compile the contract by running:
npx hardhat compile

You should see a success message indicating 1 Solidity file compiled with zero errors.

Seeing compilation errors?

Check that @openzeppelin/contracts is installed. Run npm list @openzeppelin/contracts and confirm a version appears.

Make sure your hardhat.config.ts sets version: "0.8.28" in the solidity section. This must be equal to or higher than the ^0.8.24 pragma in the contract.

If you see override-related errors, ensure you copied both the _update and nonces override functions exactly as shown above.

Help me debug my BuilderDAOToken compilation error

✔️ Awesome, I've got everything!

Great. Double check you have saved both hardhat.config.ts and contracts/BuilderDAOToken.sol.

ⓧ I'd like to double check the full code

Your contracts/BuilderDAOToken.sol should contain three sections in order:

  • The license, pragma, and five imports (ERC20, ERC20Permit, ERC20Votes, Nonces, Ownable).
  • The contract declaration with charterCID, the constructor minting 1,000,000 tokens, and the setCharterCID function.
  • The _update and nonces overrides, followed by the closing brace.

Scroll up and confirm each chunk is present in your file. Here is the complete hardhat.config.ts for reference:

import hardhatToolboxViem from "@nomicfoundation/hardhat-toolbox-viem";
import { defineConfig, configVariable } from "hardhat/config";

export default defineConfig({
  plugins: [hardhatToolboxViem],
  solidity: {
    version: "0.8.28",
  },
  networks: {
    arbitrumSepolia: {
      type: "http",
      url: "https://sepolia-rollup.arbitrum.io/rpc",
      chainId: 421614,
      accounts: [configVariable("DEPLOYER_PRIVATE_KEY")],
    },
  },
});

Your Solidity environment is ready and the BuilderDAO governance token compiles cleanly. Next up, you will test the token locally and deploy it live on Arbitrum Sepolia.

Test the Token Locally and Deploy to Arbitrum Sepolia

Your contract compiled successfully, which means the Solidity is valid. But a compiled contract only exists as bytecode on your machine. To prove it works with real wallets and real transactions, you need to deploy it to a live network.

In this step, you will write a deployment module using Hardhat Ignition, then deploy your token to Arbitrum Sepolia. Along the way, you will hit the most common L2 developer blocker: deploying without testnet ETH.

In this step, get ready to:
  • Write a Hardhat Ignition deployment module for BuilderDAOToken.
  • Fund your wallet with Arbitrum Sepolia testnet ETH.
  • Deploy the token and verify it on Arbiscan.
Write the Hardhat Ignition deployment module

Hardhat Ignition is a declarative deployment system. Instead of writing imperative scripts, you describe what should be deployed and Ignition handles ordering, error recovery, and transaction management.

  • Create the ignition/modules/ directory by running this command:
mkdir -p ignition/modules
  • Confirm the directory was created by running:
ls ignition/modules

You should see an empty listing with no errors. The directory exists and is ready for your module file.

  • Create a new file called BuilderDAOToken.ts inside ignition/modules/ in your editor.
  • Add the following deployment module code to BuilderDAOToken.ts:
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";

export default buildModule("BuilderDAOTokenModule", (m) => {
  // Use the first account (deployer) as the initial owner
  const deployer = m.getAccount(0);
  // Deploy BuilderDAOToken, passing deployer as constructor arg
  const token = m.contract("BuilderDAOToken", [deployer]);
  return { token };
});

What does this code do?

The buildModule function defines a named deployment ("BuilderDAOTokenModule"). Inside it, m.getAccount(0) grabs the first configured account (your deployer wallet).

The m.contract("BuilderDAOToken", [deployer]) line tells Ignition to deploy the BuilderDAOToken contract, passing the deployer address as the initialOwner constructor argument. This means the deployer receives the full 1,000,000 BLDAO token supply.

  • Save ignition/modules/BuilderDAOToken.ts.

File not found when saving?

Make sure the ignition/modules/ directory exists. If you skipped the mkdir -p command, create the folders manually in your editor.

Help me verify my directory structure

Set up your deployer private key

Your hardhat.config.ts already references configVariable("DEPLOYER_PRIVATE_KEY"). Hardhat 3 reads configuration variables from environment variables by default, so you need to export your MetaMask private key to your terminal session.

  • In MetaMask, click the three-dot menu on your account.
  • Select Account Details.
  • Click Export Private Key.
  • Enter your MetaMask password and copy the private key.

Never use a wallet with real funds

Use a dedicated testnet-only wallet. Never export and paste a private key that controls real assets. This key is for Arbitrum Sepolia testnet only.

  • Export the private key as an environment variable by running this command:

macOS

export DEPLOYER_PRIVATE_KEY="your-private-key-here"

Windows

$env:DEPLOYER_PRIVATE_KEY="your-private-key-here"

Why an environment variable?

Hardhat 3's configVariable reads values from your shell environment by default. This keeps your private key out of source code and version control. The key is only available in the terminal session where you exported it.

Attempt the deployment (designed obstacle)

Your Ignition module is ready and your private key is configured. Time to deploy. But there is a catch that every L2 developer hits on their first deployment.

  • Before you run the next command, think about this: your wallet has 0 ETH on Arbitrum Sepolia. What do you predict will happen when you try to deploy?
  • Attempt the deployment by running this command:
npx hardhat ignition deploy ignition/modules/BuilderDAOToken.ts --network arbitrumSepolia

What about the confirmation prompt?

Hardhat Ignition prompts you to confirm when deploying to a live network (any chain ID other than 31337). Type y and press Enter to proceed.

The deployment fails. You will see an error about insufficient funds for the transaction. Your wallet address exists on Arbitrum Sepolia, but it holds 0 ETH, so it cannot pay gas fees.

Why does this happen?

Every transaction on a blockchain requires gas fees. On Arbitrum Sepolia, gas is paid in testnet ETH. A fresh wallet starts with a zero balance, so any deployment attempt is rejected by the network until you fund it.

This is the faucet workflow that every L2 developer encounters on their first deployment.

  • Copy your wallet address from MetaMask (click your account name to copy it to your clipboard).
  • Open ethfaucet.com in your browser.
  • Select Arbitrum Sepolia as the network.
  • Paste your wallet address and request testnet ETH.
  • Wait for the faucet transaction to confirm. This usually takes a few seconds on Arbitrum Sepolia.

Faucet not working?

If ethfaucet.com is unavailable, the official Arbitrum docs list alternative options including Alchemy's Sepolia Faucet and other community faucets.

  • Confirm you received testnet ETH by checking your MetaMask balance on the Arbitrum Sepolia network. You should see a small ETH balance (greater than 0).
  • Now deploy again by running the same command:
npx hardhat ignition deploy ignition/modules/BuilderDAOToken.ts --network arbitrumSepolia

This time, Ignition processes the module, sends the deployment transaction, and prints the deployed contract address. Copy this address. You will need it in the next step.

What does the output mean?

Hardhat Ignition executes the BuilderDAOToken deployment in batches, waits for on-chain confirmation, and prints the contract address when complete.

Ignition also creates an ignition/deployments/ folder that stores the deployment state. If the deployment were interrupted, you could re-run the same command and Ignition would resume from where it left off.

  • Copy the deployed contract address from the terminal output.
  • Open https://sepolia.arbiscan.io in your browser.
  • Paste your contract address into the search bar and press Enter.

You should see your BuilderDAOToken contract page. The page shows the contract creator (your wallet address) and the creation transaction.

Contract not showing on Arbiscan?

Block explorers can take a few seconds to index new contracts. Wait 10-15 seconds and refresh the page. If it still does not appear, double-check you copied the full address from the terminal output.

Help me troubleshoot my deployment

✔️ Awesome, I've got everything!

Great. Make sure you have saved the deployed contract address somewhere. You will need it in the next step to connect your React frontend.

ⓧ I'd like to double check the full code

Here is the complete Ignition module. Your contracts/BuilderDAOToken.sol and hardhat.config.ts files are unchanged from Step 1.

import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";

export default buildModule("BuilderDAOTokenModule", (m) => {
  const deployer = m.getAccount(0);
  const token = m.contract("BuilderDAOToken", [deployer]);
  return { token };
});

Your governance token is live on Arbitrum Sepolia. Next up, you will build a React frontend so real users can connect their wallets and interact with your token.

Scaffold the React Frontend and Connect to the Token

Your token contract lives on Arbitrum Sepolia, but right now the only way to interact with it is through a block explorer. Real users need a UI to connect their wallet, see their balance, and eventually vote.

In this step you will scaffold a React frontend, wire it up to the deployed contract, and display your BLDAO balance on screen. This proves the contract is usable by real people with real wallets.

In this step, get ready to:
  • Scaffold a Vite + React + TypeScript project and install Web3 libraries.
  • Create the wagmi config with the Arbitrum Sepolia chain and wrap the app in providers.
  • Build a TokenDashboard component that reads your on-chain BLDAO balance.
Scaffold the Vite project and install dependencies

You will create the frontend as a separate project inside a frontend/ directory at the root of your Hardhat project.

  • In your terminal, make sure you are in the root of your Hardhat project (the folder containing hardhat.config.ts).
  • Scaffold the frontend by running this command:
npm create vite@latest frontend -- --template react-ts

What does this command do?

This uses Vite's scaffolding tool to generate a React + TypeScript starter project inside a new frontend/ directory. The --template react-ts flag selects the React TypeScript template and the double dash (--) passes the flag through to the underlying create-vite package.

  • Move into the frontend directory and install its base dependencies by running:
cd frontend
npm install
  • Install the Web3 libraries your frontend needs by running:
npm install wagmi viem @tanstack/react-query

What are these libraries?

wagmi provides React hooks for reading contract state and sending transactions. viem is the low-level TypeScript client that wagmi uses under the hood. @tanstack/react-query handles caching and refetching of on-chain data automatically.

  • Confirm the packages installed by running:
ls node_modules/wagmi node_modules/viem node_modules/@tanstack/react-query

You should see the contents of each package directory listed without errors.

Seeing permission errors or ENOENT?

Make sure you ran cd frontend before npm install. The packages must be installed inside the frontend/ project, not in the Hardhat root.

help me debug my npm install error in the frontend directory

Create the wagmi config and ABI file

Before building any components, you need two foundational files: one that tells wagmi which chain to connect to, and one that describes your contract's interface so TypeScript can type-check your calls.

  • Create a new file at frontend/src/wagmi.ts and paste the following code:
import { createConfig, http } from "wagmi";
import { defineChain } from "viem";

export const arbitrumSepolia = defineChain({
  id: 421614,
  name: "Arbitrum Sepolia",
  nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
  rpcUrls: {
    default: { http: ["https://sepolia-rollup.arbitrum.io/rpc"] },
  },
  blockExplorers: {
    default: { name: "Arbiscan", url: "https://sepolia.arbiscan.io" },
  },
  testnet: true,
});

export const config = createConfig({
  chains: [arbitrumSepolia],
  transports: {
    [arbitrumSepolia.id]: http(),
  },
});

What does this code do?

  • defineChain registers Arbitrum Sepolia (chain ID 421614) with its RPC URL and block explorer so wagmi knows where to send requests.
  • createConfig builds the wagmi configuration object that all hooks will share. The http() transport tells wagmi to use standard JSON-RPC over HTTP.
  • Save frontend/src/wagmi.ts.
  • Create a new file at frontend/src/abi.ts and paste the following code:
export const tokenAbi = [
  {
    type: "function",
    name: "symbol",
    inputs: [],
    outputs: [{ name: "", type: "string" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "decimals",
    inputs: [],
    outputs: [{ name: "", type: "uint8" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "balanceOf",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "getVotes",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
    stateMutability: "view",
  },
] as const;

What is this ABI file?

An ABI (Application Binary Interface) tells your frontend the exact shape of each contract function. wagmi uses this to generate fully typed hooks. The as const assertion lets TypeScript infer the exact return types from each entry.

  • Save frontend/src/abi.ts.
  • Now add the remaining ABI entries for delegate, delegates, charterCID, and setCharterCID by pasting this below the getVotes entry (before the closing ] as const):
  {
    type: "function",
    name: "delegate",
    inputs: [{ name: "delegatee", type: "address" }],
    outputs: [],
    stateMutability: "nonpayable",
  },
  {
    type: "function",
    name: "delegates",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "address" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "charterCID",
    inputs: [],
    outputs: [{ name: "", type: "string" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "setCharterCID",
    inputs: [{ name: "cid", type: "string" }],
    outputs: [],
    stateMutability: "nonpayable",
  },

Why add functions we are not using yet?

Including delegate, getVotes, and the charter functions now means you will not need to edit this file again in later steps. The ABI is complete for the entire project from this point forward.

  • Save frontend/src/abi.ts.
  • Start the dev server to verify there are no import errors by running:
npm run dev

You should see Vite print a local URL (typically http://localhost:5173). This confirms your config and ABI files have no syntax errors.

  • Press Ctrl+C to stop the dev server for now.

Dev server fails to start?

Check that you are inside the frontend/ directory when running npm run dev. Also confirm there are no stray commas or missing quotes in abi.ts.

help me fix my Vite dev server startup error

Build the TokenDashboard component and connect MetaMask

With the config in place, you can now build the component that connects a wallet and reads your BLDAO balance directly from the Arbitrum Sepolia chain.

  • Create the directory frontend/src/components/ by running:
mkdir -p src/components
  • Create a new file at frontend/src/components/TokenDashboard.tsx and paste the imports and contract address constant:
import { useAccount, useConnect, useDisconnect, useReadContract } from "wagmi";
import { formatUnits } from "viem";
import { tokenAbi } from "../abi";

// Replace with your deployed contract address from Step 2
const TOKEN_ADDRESS = "your-deployed-contract-address-here" as const;

What is TOKEN_ADDRESS?

This is the address Hardhat Ignition printed when you deployed in Step 2. Paste your actual deployed address between the quotes. The as const assertion tells TypeScript to treat it as a literal address type.

  • Replace your-deployed-contract-address-here with the contract address you recorded in Step 2 (it starts with 0x).
  • Below the constant, paste the component function with the wagmi hooks:
export function TokenDashboard() {
  const { address, isConnected } = useAccount();
  const { connect, connectors } = useConnect();
  const { disconnect } = useDisconnect();

  const { data: symbol } = useReadContract({
    address: TOKEN_ADDRESS,
    abi: tokenAbi,
    functionName: "symbol",
  });

  const { data: decimals } = useReadContract({
    address: TOKEN_ADDRESS,
    abi: tokenAbi,
    functionName: "decimals",
  });

  const { data: balance } = useReadContract({
    address: TOKEN_ADDRESS,
    abi: tokenAbi,
    functionName: "balanceOf",
    args: address ? [address] : undefined,
    query: { enabled: !!address },
  });

What do these hooks do?

  • useAccount returns the connected wallet address and connection status.
  • useConnect and useDisconnect handle MetaMask login and logout.
  • Each useReadContract call reads a single view function from your deployed token. The query: { enabled: !!address } on balanceOf prevents the call from firing before a wallet is connected.
  • Below the hooks, paste the component's return JSX to complete the file:
  if (!isConnected) {
    return (
      <div>
        <button onClick={() => connect({ connector: connectors[0] })}>
          Connect Wallet
        </button>
      </div>
    );
  }

  return (
    <div>
      <p>Connected: {address}</p>
      <button onClick={() => disconnect()}>Disconnect</button>
      <section>
        <h2>Token Balance</h2>
        <p>
          {balance !== undefined && decimals !== undefined
            ? formatUnits(balance, decimals)
            : "Loading..."}{" "}
          {symbol ?? ""}
        </p>
      </section>
    </div>
  );
}

What does the JSX render?

When no wallet is connected, the component renders a single "Connect Wallet" button. Once MetaMask is connected, it shows the wallet address, a disconnect button, and the token balance formatted from raw wei to human-readable units using formatUnits.

  • Save frontend/src/components/TokenDashboard.tsx.
  • Now replace the contents of frontend/src/App.tsx with the following code to wire everything together:
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./wagmi";
import { TokenDashboard } from "./components/TokenDashboard";

const queryClient = new QueryClient();

export default function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <main>
          <h1>BuilderDAO Governance</h1>
          <TokenDashboard />
        </main>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

What do the providers do?

WagmiProvider makes the chain config available to every wagmi hook in the tree. QueryClientProvider from TanStack Query handles caching and automatic refetching of on-chain reads.

  • Save frontend/src/App.tsx.
  • Before you run the dev server, what do you think you will see when you connect MetaMask with your deployer wallet?
  • Start the dev server by running:
npm run dev
  • Open the URL printed by Vite (typically http://localhost:5173) in your browser.
  • Click Connect Wallet.
  • MetaMask will pop up. Make sure you select the wallet that deployed the contract and approve the connection.
  • Confirm MetaMask is on the Arbitrum Sepolia network. If it prompts you to switch networks, approve the switch.

You should see your wallet address displayed, along with 1000000.0 BLDAO (the full initial supply you minted to yourself as the deployer).

Seeing 0 balance or Loading...?

Double-check that TOKEN_ADDRESS in TokenDashboard.tsx matches the address Hardhat Ignition printed. It must start with 0x and be wrapped in quotes.

Also confirm MetaMask is connected on Arbitrum Sepolia (chain ID 421614), not Ethereum mainnet or another network.

help me debug why my token balance shows 0 or Loading in the React frontend

✔️ Awesome, I've got everything!

Great. Double-check you have saved all four files: wagmi.ts, abi.ts, App.tsx, and TokenDashboard.tsx.

ⓧ I'd like to double check the full code

Here are the complete files you should have at this point. Scroll up and confirm each matches.

frontend/src/wagmi.ts

import { createConfig, http } from "wagmi";
import { defineChain } from "viem";

export const arbitrumSepolia = defineChain({
  id: 421614,
  name: "Arbitrum Sepolia",
  nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
  rpcUrls: {
    default: { http: ["https://sepolia-rollup.arbitrum.io/rpc"] },
  },
  blockExplorers: {
    default: { name: "Arbiscan", url: "https://sepolia.arbiscan.io" },
  },
  testnet: true,
});

export const config = createConfig({
  chains: [arbitrumSepolia],
  transports: {
    [arbitrumSepolia.id]: http(),
  },
});

frontend/src/abi.ts

export const tokenAbi = [
  {
    type: "function",
    name: "symbol",
    inputs: [],
    outputs: [{ name: "", type: "string" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "decimals",
    inputs: [],
    outputs: [{ name: "", type: "uint8" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "balanceOf",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "getVotes",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "delegate",
    inputs: [{ name: "delegatee", type: "address" }],
    outputs: [],
    stateMutability: "nonpayable",
  },
  {
    type: "function",
    name: "delegates",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "address" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "charterCID",
    inputs: [],
    outputs: [{ name: "", type: "string" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "setCharterCID",
    inputs: [{ name: "cid", type: "string" }],
    outputs: [],
    stateMutability: "nonpayable",
  },
] as const;

frontend/src/App.tsx

import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./wagmi";
import { TokenDashboard } from "./components/TokenDashboard";

const queryClient = new QueryClient();

export default function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <main>
          <h1>BuilderDAO Governance</h1>
          <TokenDashboard />
        </main>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

frontend/src/components/TokenDashboard.tsx

import { useAccount, useConnect, useDisconnect, useReadContract } from "wagmi";
import { formatUnits } from "viem";
import { tokenAbi } from "../abi";

// Replace with your deployed contract address from Step 2
const TOKEN_ADDRESS = "your-deployed-contract-address-here" as const;
export function TokenDashboard() {
  const { address, isConnected } = useAccount();
  const { connect, connectors } = useConnect();
  const { disconnect } = useDisconnect();

  const { data: symbol } = useReadContract({
    address: TOKEN_ADDRESS,
    abi: tokenAbi,
    functionName: "symbol",
  });

  const { data: decimals } = useReadContract({
    address: TOKEN_ADDRESS,
    abi: tokenAbi,
    functionName: "decimals",
  });

  const { data: balance } = useReadContract({
    address: TOKEN_ADDRESS,
    abi: tokenAbi,
    functionName: "balanceOf",
    args: address ? [address] : undefined,
    query: { enabled: !!address },
  });
  if (!isConnected) {
    return (
      <div>
        <button onClick={() => connect({ connector: connectors[0] })}>
          Connect Wallet
        </button>
      </div>
    );
  }

  return (
    <div>
      <p>Connected: {address}</p>
      <button onClick={() => disconnect()}>Disconnect</button>
      <section>
        <h2>Token Balance</h2>
        <p>
          {balance !== undefined && decimals !== undefined
            ? formatUnits(balance, decimals)
            : "Loading..."}{" "}
          {symbol ?? ""}
        </p>
      </section>
    </div>
  );
}

Your frontend is live and reading real on-chain data from Arbitrum Sepolia. Next up, you will add a voting power display and discover why it stubbornly shows 0 even though you hold a million tokens.

Expose the Delegation Gap and Fix It

Your dashboard shows a token balance, but governance tokens are about more than ownership. They are about voting power. In this step, you will add a voting power display and discover that it reads 0 despite holding 1,000,000 BLDAO.

That zero is not a bug. It is the core mechanic of ERC20Votes: token holders must explicitly delegate their voting power before it activates. You will experience this firsthand, then fix it by implementing self-delegation.

In this step, get ready to:
  • Display on-chain voting power from the getVotes function.
  • Hit the designed obstacle: voting power shows 0 despite holding tokens.
  • Implement self-delegation to activate your voting power.
Add the voting power display

The getVotes function on your contract returns how much voting power an address currently holds. You already have it in your ABI. Now you will wire it up to the UI.

  • In frontend/src/components/TokenDashboard.tsx, find the balance useReadContract hook (the one reading balanceOf).
  • Add this voting power read directly below it:
  const { data: votingPower } = useReadContract({
    address: TOKEN_ADDRESS,
    abi: tokenAbi,
    functionName: "getVotes",
    args: address ? [address] : undefined,
    query: { enabled: !!address },
  });
  • Now scroll down to the return statement. Find the closing </section> tag of the Token Balance section.
  • Add this Voting Power section directly below it:
      <section>
        <h2>Voting Power</h2>
        <p>
          {votingPower !== undefined && decimals !== undefined
            ? formatUnits(votingPower, decimals)
            : "Loading..."}{" "}
          {symbol ?? ""}
        </p>
        {votingPower === 0n && balance !== undefined && balance > 0n && (
          <p>
            Your voting power is 0 even though you hold tokens. ERC20Votes
            requires explicit delegation before voting power activates.
          </p>
        )}
      </section>

What does this code do?

  • The useReadContract hook calls getVotes(address) on your deployed contract, which returns how much voting power the connected address holds.
  • The conditional render at the bottom checks if votingPower === 0n while the balance is positive. If so, it shows a warning explaining why.

Before you check the browser, make a prediction: you hold 1,000,000 BLDAO tokens. What do you think your voting power will show?

  • Save TokenDashboard.tsx.
  • Switch back to your browser where the dev server is running and refresh the page.

Your Voting Power reads 0. You hold 1,000,000 BLDAO but have zero voting power. The warning message appears confirming this gap.

Why is my voting power 0?

This is intentional. ERC20Votes uses a checkpoint-based system that tracks historical voting power at specific block numbers. This prevents double-voting during proposals.

To activate this system, token holders must explicitly call delegate() to assign their voting weight. You can delegate to yourself (self-delegation) or to another address. Until you delegate, your tokens have no governance weight.

Seeing 'Loading...' instead of 0?

  • Make sure your MetaMask is connected to Arbitrum Sepolia and the same account that deployed the contract.
  • Confirm TOKEN_ADDRESS in the file still matches your deployed contract address.
  • Check the browser console for RPC errors. The public endpoint may be rate-limited.

Help me debug why my voting power isn't loading from the contract.

Implement self-delegation to activate voting power

Now that you have seen the gap, you will fix it. The delegate function on your contract assigns voting power to any address. Passing your own address as the delegatee is called self-delegation.

  • In TokenDashboard.tsx, find the import line at the top of the file:
import { useAccount, useConnect, useDisconnect, useReadContract } from "wagmi";
  • Replace it with this updated import that adds the write hooks:
import { useAccount, useConnect, useDisconnect, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
  • Now find the votingPower useReadContract hook you just added. Add these write hooks and the delegate handler directly below it:
  const { writeContract, data: txHash, isPending } = useWriteContract();

  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
    hash: txHash,
  });

  function handleDelegate() {
    if (!address) return;
    writeContract({
      address: TOKEN_ADDRESS,
      abi: tokenAbi,
      functionName: "delegate",
      args: [address],
    });
  }

What does this code do?

  • useWriteContract gives you writeContract (the function to send a transaction), txHash (the transaction hash once submitted), and isPending (true while waiting for wallet confirmation).
  • useWaitForTransactionReceipt watches the chain for the transaction to be mined. isConfirming is true while waiting. isSuccess becomes true once the block is confirmed.
  • handleDelegate calls delegate(address) on the contract, passing the connected address as the delegatee. This is self-delegation.
  • Finally, find the closing </section> tag of the Voting Power section you added earlier. Add this delegate button section directly below it:
      <section>
        <button onClick={handleDelegate} disabled={isPending || isConfirming}>
          {isPending ? "Confirm in wallet..." : isConfirming ? "Confirming..." : "Delegate to Self"}
        </button>
        {isSuccess && <p>Delegation successful! Refresh to see updated voting power.</p>}
      </section>

What does the button do?

The button is disabled while a transaction is pending or confirming, preventing double-clicks. Its label changes to reflect the current state: waiting for wallet approval, waiting for on-chain confirmation, or ready to delegate.

Once isSuccess flips to true, a success message appears telling the learner to refresh for updated voting power.

  • Save TokenDashboard.tsx and refresh your browser.
  • You should see the new Delegate to Self button appear below the Voting Power section.

Button not appearing?

  • Check the browser console for a compilation error. A missing comma or bracket in the JSX will prevent the page from rendering.
  • Make sure the new <section> is inside the main <div> of the return statement, after the Voting Power section.

Help me fix my delegate button not rendering.

Delegate your votes and verify the fix

Time for the payoff. You are about to call delegate() on the live Arbitrum Sepolia contract. What do you predict will happen to your voting power after the transaction confirms?

  • Click the Delegate to Self button.
  • MetaMask will pop up asking you to confirm the transaction. The button text changes to "Confirm in wallet..." while you decide.
  • Click Confirm in MetaMask.
  • The button text changes to "Confirming..." while the transaction is mined on Arbitrum Sepolia. This takes a few seconds.
  • Once confirmed, you will see "Delegation successful! Refresh to see updated voting power." appear below the button.
  • Refresh the page in your browser.

Your Voting Power now shows 1000000 BLDAO, matching your token balance exactly. The warning message disappears because votingPower is no longer 0.

Why does governance require explicit delegation?

On-chain governance uses snapshot-based voting. When a proposal is created, it records the block number. Only voting power delegated before that block counts. This prevents someone from buying tokens after a proposal starts and swinging the vote.

Delegation also prevents double-voting. If you transfer tokens after voting, the recipient cannot vote again with those same tokens because checkpoints track who had power at the snapshot block, not who holds tokens now.

Voting power still showing 0 after refresh?

  • Wait a few more seconds and refresh again. Arbitrum Sepolia occasionally has short finality delays.
  • Open https://sepolia.arbiscan.io and search for your wallet address. Check that the delegate transaction shows "Success" status.
  • If the transaction failed, you may have insufficient Sepolia ETH for gas. Get more from a faucet and try again.

Help me debug why my voting power didn't update after delegation.

✔️ Awesome, I've got everything!

Great. Double check you have saved TokenDashboard.tsx and that your voting power matches your token balance in the browser.

ⓧ I'd like to double check the full code

Your TokenDashboard.tsx should contain these pieces in order. Scroll up and confirm each one is present:

  • The import line with useAccount, useConnect, useDisconnect, useReadContract, useWriteContract, useWaitForTransactionReceipt from wagmi.
  • The formatUnits import from viem and the tokenAbi import.
  • TOKEN_ADDRESS constant with your deployed contract address.
  • Four useReadContract hooks: symbol, decimals, balance, votingPower.
  • The useWriteContract hook destructuring writeContract, data: txHash, isPending.
  • The useWaitForTransactionReceipt hook destructuring isLoading: isConfirming and isSuccess.
  • The handleDelegate function calling writeContract with functionName: "delegate" and args: [address].
  • JSX: Connect Wallet button (when disconnected), address display + disconnect, Token Balance section, Voting Power section with conditional warning, and Delegate button section with pending/confirming states.

You have just experienced the fundamental governance mechanic that powers Uniswap, Compound, and Arbitrum DAO. Holding tokens is not enough. Delegation is the activation step that turns passive holdings into active governance weight. Next up, you will take on the Secret Mission and connect decentralized storage to your on-chain governance system.

Secret mission

Store the DAO Charter on IPFS

A governance token is just the voting mechanism. Every DAO needs a constitution that defines what members are voting about. In this mission, you will write a charter document, upload it to IPFS for permanent decentralized storage, and record the content hash on-chain so the contract itself points to the DAO's founding principles.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on Arbitrum Sepolia (a testnet) and uses local files, so there are no ongoing costs. Decide whether to keep your resources for future reference or delete them entirely.

Resources you used:

  • Local project files on your Desktop (the folder containing hardhat.config.ts, contracts/, frontend/, and node_modules/).
  • BuilderDAOToken smart contract deployed on Arbitrum Sepolia (testnet, zero cost, cannot be deleted from the blockchain).
  • IPFS-pinned charter document on your pinning service free tier (Secret Mission).
  • Testnet ETH in your MetaMask wallet (valueless, no cleanup needed).

Keep everything running

No action needed. Choose this if you want to keep building on this project or reference the code later.

  • Your deployed contract persists on Arbitrum Sepolia indefinitely at zero cost. Testnets have no real monetary value.
  • Your IPFS-pinned charter remains available on the free tier of your pinning service.
  • Your local project files stay on your Desktop for future reference.
  • Testnet ETH in your MetaMask wallet is worthless and takes up no space.

Pause - I'll come back to this later

Shut down any running processes to free up memory. Your files and deployed contract stay intact for when you return.

  • If your Vite dev server is still running in the terminal, press Ctrl+C to stop it.
  • Everything else is already idle. The testnet contract, IPFS pin, and local files all persist without any running processes.

Delete - I don't want to use this again

Remove all local project resources. The testnet contract and testnet ETH cannot be deleted, but they cost nothing.

  • Stop the Vite dev server if it is still running (Ctrl+C in the terminal).
  • Delete your project folder from the Desktop. On macOS or Linux, run the following command (replacing the folder name with your actual project folder name):
cd ~/Desktop
rm -rf your-project-folder-name
  • On Windows, run:
cd ~\Desktop
rmdir /s /q your-project-folder-name
  • To remove your IPFS-pinned charter document, log into your pinning service (Pinata or web3.storage). Navigate to your files page, click the three-dot menu next to the charter file, and select Delete.
  • The deployed BuilderDAOToken contract on Arbitrum Sepolia cannot be removed from the blockchain. This is normal. It persists at zero cost since it is on a testnet.
  • Testnet ETH in your MetaMask wallet is worthless and does not need to be removed.

Nice Work!

Nice Work!

You just built and deployed a fully functional governance token for BuilderDAO on a live L2 testnet. You proved that on-chain governance is more than holding tokens. It requires explicit delegation to activate voting power.

Here's what you learned:

  • Wrote and deployed an ERC-20 governance token with the ERC20Votes extension on Arbitrum Sepolia, experiencing sub-second confirmation times on a real optimistic rollup.
  • Discovered firsthand why voting power requires explicit delegation. Your balance showed 1,000,000 BLDAO but your voting power showed 0 until you called delegate.
  • Built a React frontend with wagmi and viem that connects a wallet, reads on-chain state, and submits delegation transactions with full TypeScript type safety.
  • Secret Mission: Connected decentralized storage (IPFS) to on-chain state by storing a DAO charter CID in the contract.

Ready to quiz yourself?