Building Token Gate: On-Chain Access Control with Solidity and React

Token Gate is a Web3 dApp that restricts features to wallets holding a minimum ERC-20 balance or NFT count. The owner defines rules on-chain; users either pass checkAccess() or cannot execute gated actions. A React frontend on Conflux eSpace Testnet (chain ID 71) connects via wagmi and viem.

Problem & solution

Many apps hide buttons in the UI and call it “token gating.” That is not secure—anyone can call the contract directly.

Token Gate enforces access in Solidity:

  • Owner publishes rules (token address + minimum balance).
  • checkAccess(user) returns true if any active rule passes (OR logic).
  • Gated functions (e.g. postMessage) call require(checkAccess(msg.sender)).

The frontend is a convenience layer; security lives on-chain.


Architecture

┌─────────────────┐     read/write      ┌──────────────────┐
│  React + wagmi  │ ◄─────────────────► │   TokenGate.sol  │
│  (frontend/)    │   ABI + address     │  (on-chain)      │
└────────┬────────┘                     └────────┬─────────┘
         │                                       │
         │ MetaMask                              │ balanceOf()
         ▼                                       ▼
┌─────────────────┐                     ┌──────────────────┐
│ Conflux eSpace  │                     │ ERC-20 / ERC-721 │
│ Testnet (71)    │                     │ (e.g. TestToken) │
└─────────────────┘                     └──────────────────┘
Layer Tech
Smart contracts Solidity ^0.8.20
Frontend React, Vite, TypeScript
Web3 wagmi, viem
Network Conflux eSpace Testnet (chain ID 71)

Smart contract: TokenGate

File: tokengated.sol

Rule model

enum GateType { ERC20, ERC721 }

struct Rule {
    GateType gateType;
    address token;
    uint minBalance;
    bool active;
}

Rule[] public rules;
address public owner;
  • ERC20minBalance is a token amount (e.g. 1000).
  • ERC721minBalance is usually 1 (at least one NFT).

Add rule (owner only)

function addRule(
    GateType _type,
    address _token,
    uint _minBalance
) public {
    require(msg.sender == owner, "Not owner");
    require(_token != address(0), "Invalid token");
    require(_minBalance > 0, "Invalid balance");

    rules.push(Rule({
        gateType: _type,
        token: _token,
        minBalance: _minBalance,
        active: true
    }));
}

Check access (OR logic)

function checkAccess(address user) public view returns (bool) {
    for (uint i = 0; i < rules.length; i++) {
        if (!rules[i].active) continue;

        uint balance = getBalance(rules[i], user);

        if (balance >= rules[i].minBalance) {
            return true;
        }
    }
    return false;
}

function getBalance(Rule memory rule, address user) internal view returns (uint) {
    if (rule.gateType == GateType.ERC20) {
        return IERC20(rule.token).balanceOf(user);
    } else {
        return IERC721(rule.token).balanceOf(user);
    }
}

Gated action + event

event MessagePosted(address user, string message);

function postMessage(string memory message) public {
    require(checkAccess(msg.sender), "No access");
    emit MessagePosted(msg.sender, message);
}

Toggle rule

function toggleRule(uint _id, bool _active) public {
    require(msg.sender == owner, "Not owner");
    require(_id < rules.length, "Invalid rule");
    rules[_id].active = _active;
}

Test token

File: toke.sol — minimal ERC-20–style mock for demos on testnet.

pragma solidity ^0.8.20;

contract TestToken {
    mapping(address => uint) public balanceOf;

    constructor() {
        balanceOf[msg.sender] = 1000;
    }
}

The deployer receives 1000 tokens. Use this contract address when calling addRule with GateType.ERC20 and a minBalance such as 100 or 1000.

Note: Add a transfer function if you need to test multiple wallets.


Frontend :left_right_arrow: contract connection

1. Contract address from environment

File: frontend/src/config/contracts.ts

import type { Address } from 'viem'

export const TOKEN_GATE_ADDRESS = import.meta.env
  .VITE_TOKEN_GATE_ADDRESS as Address | undefined

export const isConfigured = Boolean(
  TOKEN_GATE_ADDRESS &&
    TOKEN_GATE_ADDRESS.startsWith('0x') &&
    TOKEN_GATE_ADDRESS.length === 42,
)

File: frontend/.env.example

VITE_TOKEN_GATE_ADDRESS=0x0000000000000000000000000000000000000000

2. Conflux eSpace Testnet chain

File: frontend/src/config/chains.ts

import { defineChain } from 'viem'

export const confluxESpaceTestnet = defineChain({
  id: 71,
  name: 'Conflux eSpace Testnet',
  nativeCurrency: {
    name: 'Conflux',
    symbol: 'CFX',
    decimals: 18,
  },
  rpcUrls: {
    default: {
      http: ['https://evmtestnet.confluxrpc.com'],
    },
  },
  blockExplorers: {
    default: {
      name: 'ConfluxScan',
      url: 'https://evmtestnet.confluxscan.org',
    },
  },
  testnet: true,
})

export const TARGET_CHAIN_ID = confluxESpaceTestnet.id

3. wagmi config

File: frontend/src/config/wagmi.ts

import { http, createConfig } from 'wagmi'
import { injected } from 'wagmi/connectors'
import { confluxESpaceTestnet } from './chains'

export const wagmiConfig = createConfig({
  chains: [confluxESpaceTestnet],
  connectors: [injected()],
  transports: {
    [confluxESpaceTestnet.id]: http('https://evmtestnet.confluxrpc.com'),
  },
})

4. ABI (excerpt)

File: frontend/src/abis/tokenGate.ts — generated from TokenGate compile output.

export const tokenGateAbi = [
  {
    type: 'function',
    name: 'addRule',
    inputs: [
      { name: '_type', type: 'uint8' },
      { name: '_token', type: 'address' },
      { name: '_minBalance', type: 'uint256' },
    ],
    outputs: [],
    stateMutability: 'nonpayable',
  },
  {
    type: 'function',
    name: 'checkAccess',
    inputs: [{ name: 'user', type: 'address' }],
    outputs: [{ name: '', type: 'bool' }],
    stateMutability: 'view',
  },
  {
    type: 'function',
    name: 'postMessage',
    inputs: [{ name: 'message', type: 'string' }],
    outputs: [],
    stateMutability: 'nonpayable',
  },
  // ... toggleRule, rules, ruleCount, MessagePosted event, etc.
] as const

5. Read access on-chain

File: frontend/src/App.tsx

const { data: hasAccess, isLoading: accessLoading } = useReadContract({
  address: TOKEN_GATE_ADDRESS,
  abi: tokenGateAbi,
  functionName: 'checkAccess',
  args: address ? [address] : undefined,
  query: { enabled: isConfigured && Boolean(address) && onTargetChain },
})

6. Write contract (add rule, toggle, post message)

const { writeContract, data: txHash, isPending } = useWriteContract()

const runTx = useCallback(
  (fn: 'addRule' | 'toggleRule' | 'postMessage', args: readonly unknown[]) => {
    if (!isConfigured || !TOKEN_GATE_ADDRESS) return
    writeContract({
      address: TOKEN_GATE_ADDRESS,
      abi: tokenGateAbi,
      functionName: fn,
      args: args as never,
    })
  },
  [writeContract],
)

const handleAddRule = (gateType: 0 | 1, token: `0x${string}`, minBalance: bigint) => {
  runTx('addRule', [gateType, token, minBalance])
}

const handleToggle = (id: number, active: boolean) => {
  runTx('toggleRule', [BigInt(id), active])
}

const handlePost = (message: string) => {
  runTx('postMessage', [message])
}
UI action Contract call Arguments
Add rule addRule 0 = ERC20, 1 = ERC721, token address, min balance
Enable/disable rule toggleRule rule index, true / false
Post message postMessage message string

7. Listen for MessagePosted events

useWatchContractEvent({
  address: TOKEN_GATE_ADDRESS,
  abi: tokenGateAbi,
  eventName: 'MessagePosted',
  enabled: isConfigured,
  onLogs(logs) {
    const next = logs.map((log) => ({
      user: truncateAddress(log.args.user!),
      message: log.args.message ?? '',
    }))
    setMessages((prev) => [...next.reverse(), ...prev].slice(0, 20))
  },
})

8. Fetch all rules

File: frontend/src/hooks/useRules.ts

export function useRules() {
  const { data: count, refetch: refetchCount } = useReadContract({
    address: TOKEN_GATE_ADDRESS,
    abi: tokenGateAbi,
    functionName: 'ruleCount',
    query: { enabled: isConfigured },
  })

  const ruleCount = Number(count ?? 0n)

  const contracts = useMemo(
    () =>
      Array.from({ length: ruleCount }, (_, id) => ({
        address: TOKEN_GATE_ADDRESS!,
        abi: tokenGateAbi,
        functionName: 'rules' as const,
        args: [BigInt(id)] as const,
      })),
    [ruleCount],
  )

  const { data: ruleResults, refetch: refetchRules } = useReadContracts({
    contracts,
    query: { enabled: isConfigured && ruleCount > 0 },
  })

  // ... map results to GateRule[]
}

Conflux eSpace Testnet setup

Add this network in MetaMask:

Field Value
Network name Conflux eSpace Testnet
RPC URL https://evmtestnet.confluxrpc.com
Chain ID 71
Currency symbol CFX
Block explorer https://evmtestnet.confluxscan.org

Get test CFX from the Conflux faucet if needed.


Deploy & run

Smart contracts (Remix)

  1. Environment: Injected Provider - MetaMask on Conflux eSpace Testnet.
  2. Deploy TestToken (toke.sol).
  3. Deploy TokenGate (tokengated.sol).
  4. On TokenGate, call addRule(0, <TestTokenAddress>, 1000).

Frontend

cd frontend
cp .env.example .env
# Set VITE_TOKEN_GATE_ADDRESS to your deployed TokenGate address
npm install
npm run dev

Open the app, connect MetaMask on chain 71, and verify Access: Granted for a wallet that holds enough tokens.


Demo flow

  1. Owner wallet — Role: Owner, Access: Granted. Add rules, post messages.
  2. Switch wallet — Role: User, Access: Denied. Gated UI disabled.
  3. Holder wallet — Access: Granted if balanceOf >= minBalance for an active rule.
  4. Code path — UI → writeContract → MetaMask sign → TokenGate on Conflux testnet.
Owner sets rule → User holds token → checkAccess(true) → postMessage succeeds
                                      ↓
                              MessagePosted event → UI updates

Production notes

Before mainnet:

  • Use OpenZeppelin Ownable instead of a manual owner field.
  • Add pausability and input validation for production tokens.
  • Consider AND logic or rule groups if you need stricter gating.
  • Optimize gas when rules.length grows (mapping + indexes).
  • Never rely on frontend-only checks—always require on-chain.
  • Do not commit frontend/.env (use .env.example only).

What’s next

Possible extensions:

  • transfer on TestToken for multi-wallet testing
  • Hardhat deploy scripts + verified contracts on ConfluxScan
  • ERC-721 rule demo with a test NFT
  • IPFS or events indexer for message history

Project structure

tokenGated/
├── tokengated.sol          # TokenGate contract
├── toke.sol                # TestToken mock
├── DEV_BLOG.md             # This file
└── frontend/
    ├── src/
    │   ├── App.tsx         # Main contract integration
    │   ├── abis/tokenGate.ts
    │   ├── config/chains.ts
    │   ├── config/wagmi.ts
    │   ├── config/contracts.ts
    │   ├── hooks/useRules.ts
    │   └── components/     # UI panels
    └── .env.example

Resources;
GitHub: https://github.com/Vikash-8090-Yadav/tokenGated
Video Tutorial; https://youtu.be/sltfeoE33Ng

Summary

Token Gate shows a complete loop: Solidity rules and gated functions on Conflux eSpace Testnet, plus a React UI that reads checkAccess, writes addRule / toggleRule / postMessage, and listens for events. The important idea: the contract is the source of truth; the frontend only reflects and triggers on-chain state.


Built with Solidity, React, wagmi, and Conflux eSpace Testnet.