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)returnstrueif any active rule passes (OR logic). - Gated functions (e.g.
postMessage) callrequire(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;
-
ERC20 —
minBalanceis a token amount (e.g. 1000). -
ERC721 —
minBalanceis usually1(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
transferfunction if you need to test multiple wallets.
Frontend
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)
- Environment: Injected Provider - MetaMask on Conflux eSpace Testnet.
- Deploy
TestToken(toke.sol). - Deploy
TokenGate(tokengated.sol). - On
TokenGate, calladdRule(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
- Owner wallet — Role: Owner, Access: Granted. Add rules, post messages.
- Switch wallet — Role: User, Access: Denied. Gated UI disabled.
-
Holder wallet — Access: Granted if
balanceOf >= minBalancefor an active rule. -
Code path — UI →
writeContract→ MetaMask sign →TokenGateon Conflux testnet.
Owner sets rule → User holds token → checkAccess(true) → postMessage succeeds
↓
MessagePosted event → UI updates
Production notes
Before mainnet:
- Use OpenZeppelin
Ownableinstead of a manualownerfield. - Add pausability and input validation for production tokens.
- Consider AND logic or rule groups if you need stricter gating.
- Optimize gas when
rules.lengthgrows (mapping + indexes). - Never rely on frontend-only checks—always
requireon-chain. - Do not commit
frontend/.env(use.env.exampleonly).
What’s next
Possible extensions:
-
transferonTestTokenfor 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.
contract connection