Web3 中级实战:用 Solidity + Ethers.js 构建并部署一个支持 MetaMask 登录与代币支付的 DApp
这篇文章我想带你做一个“能跑起来”的 Web3 DApp:用户打开页面,用 MetaMask 登录,然后使用 ERC-20 代币支付购买一个链上商品。
如果你已经写过 Hello World 合约,或者做过最简单的 connect wallet,那这篇内容正好适合往前走一步:把 钱包连接、合约调用、授权支付、部署上线、异常排查 串成一个完整闭环。
背景与问题
很多 Web3 初学项目都有一个问题:
要么只会“连钱包”,但没有真实业务;
要么只会写 Solidity 合约,前端却接不起来;
再要么前后都能跑,但一到“代币支付”就开始各种报错:
- 为什么
transferFrom总是失败? - 为什么 MetaMask 连上了,但切链后页面状态乱了?
- 为什么本地测试通过,部署到测试网就不行?
- 为什么用户支付前还要先
approve一次?
这些不是边角问题,而是 DApp 真正落地时最常见的主线问题。
我们这次的目标很明确:做一个最小但完整的 DApp。
目标功能
- 使用 MetaMask 连接钱包
- 展示当前账户地址
- 用户点击购买时,先授权代币,再完成支付
- 商家合约收到指定数量的 ERC-20 代币
- 前端能读取购买结果并展示状态
为了便于理解,我会把业务压缩成一个“链上商店”:
- 代币:一个测试用 ERC-20
- 商店合约:用户支付 10 个代币,记录一次购买
- 前端:React + Ethers.js
前置知识
建议你至少具备这些基础:
- 会看 Solidity 合约
- 知道 ERC-20 的
approve / allowance / transferFrom - 用过 MetaMask
- 知道测试网和主网的区别
- Node.js 与 npm 基本使用
如果你之前没系统碰过 ERC-20 授权机制,也没关系,这篇会边做边解释。
环境准备
本文示例环境:
- Node.js 18+
- npm 或 pnpm
- MetaMask
- Hardhat
- React
- Ethers.js v6
- OpenZeppelin Contracts
初始化项目可以分成两个目录:
web3-token-pay-dapp/
├─ contracts/ # Hardhat 合约工程
└─ frontend/ # React 前端
安装合约工程依赖
mkdir web3-token-pay-dapp
cd web3-token-pay-dapp
mkdir contracts
cd contracts
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts dotenv
npx hardhat
选择一个基础 JavaScript 项目即可。
安装前端依赖
cd ..
npm create vite@latest frontend -- --template react
cd frontend
npm install ethers
npm install
核心原理
在写代码前,先把支付流程搞清楚。很多坑都来自对 ERC-20 支付模型理解不完整。
1. MetaMask 登录,本质不是传统登录
在 Web3 里,所谓“MetaMask 登录”通常不是用户名密码,而是:
- 前端请求钱包连接
- 用户授权暴露账户地址
- 前端拿到地址后,基于这个地址做身份识别
- 如果业务需要更强认证,再增加签名登录
本文先做最常见的第一层:连接钱包作为身份入口。
2. ERC-20 支付为什么要两步
如果商店合约想从用户地址扣代币,不能直接扣。
ERC-20 标准要求:
- 用户先对商店合约执行
approve(spender, amount) - 商店合约再调用
transferFrom(user, merchant, amount)
原因很简单:合约不能擅自移动你钱包里的 ERC-20 代币,必须先获得授权。
3. 业务拆分
我们会部署两个合约:
MockToken.sol:测试代币TokenShop.sol:商店合约,接收代币并记录购买
flowchart TD
A[用户打开 DApp] --> B[连接 MetaMask]
B --> C[读取账户与链 ID]
C --> D[点击购买]
D --> E{allowance 是否足够}
E -- 否 --> F[调用 approve 授权]
F --> G[等待链上确认]
E -- 是 --> H[调用 buy 方法]
G --> H
H --> I[商店合约 transferFrom 扣代币]
I --> J[记录购买成功]
J --> K[前端展示结果]
合约设计
1. MockToken:测试代币
这个合约用于本地和测试网发代币给测试账户。
// contracts/contracts/MockToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockToken is ERC20 {
constructor() ERC20("Mock USD", "MUSD") {
_mint(msg.sender, 1000000 * 10 ** decimals());
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
这里我故意保留了 mint 的开放权限,原因是测试方便。
但注意:这在生产环境里绝对不该这么写。 后面安全部分会专门说。
2. TokenShop:商店合约
逻辑很简单:
- 部署时指定 ERC-20 代币地址
- 指定收款地址 merchant
- 每次购买固定价格
10 token - 支付成功后记录用户购买次数
// contracts/contracts/TokenShop.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract TokenShop {
IERC20 public immutable paymentToken;
address public immutable merchant;
uint256 public immutable price;
mapping(address => uint256) public purchaseCount;
event Purchased(
address indexed buyer,
uint256 amount,
uint256 totalPurchases
);
constructor(address _token, address _merchant, uint256 _price) {
require(_token != address(0), "invalid token");
require(_merchant != address(0), "invalid merchant");
require(_price > 0, "invalid price");
paymentToken = IERC20(_token);
merchant = _merchant;
price = _price;
}
function buy() external {
bool ok = paymentToken.transferFrom(msg.sender, merchant, price);
require(ok, "transfer failed");
purchaseCount[msg.sender] += 1;
emit Purchased(msg.sender, price, purchaseCount[msg.sender]);
}
function getPurchaseCount(address user) external view returns (uint256) {
return purchaseCount[user];
}
}
合约交互时序
这一段建议你一定看懂。前端、钱包、链上合约三者之间的关系,一旦理解顺了,排错会轻松很多。
sequenceDiagram
participant U as 用户
participant FE as 前端 DApp
participant MM as MetaMask
participant TK as MockToken
participant SH as TokenShop
U->>FE: 点击连接钱包
FE->>MM: eth_requestAccounts
MM-->>FE: 返回账户地址
U->>FE: 点击购买
FE->>TK: allowance(user, shop)
TK-->>FE: 当前授权额度
alt 授权不足
FE->>MM: 签名 approve(shop, price)
MM-->>TK: 提交交易
TK-->>FE: 授权成功
end
FE->>MM: 签名 buy()
MM-->>SH: 提交交易
SH->>TK: transferFrom(user, merchant, price)
TK-->>SH: 转账成功
SH-->>FE: 触发 Purchased 事件
实战代码(可运行)
下面开始完整搭建。
第一步:编写 Hardhat 配置
// contracts/hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
const PRIVATE_KEY = process.env.PRIVATE_KEY || "";
const RPC_URL = process.env.RPC_URL || "";
module.exports = {
solidity: "0.8.20",
networks: {
hardhat: {},
sepolia: {
url: RPC_URL,
accounts: PRIVATE_KEY ? [PRIVATE_KEY] : []
}
}
};
.env 示例:
PRIVATE_KEY=你的测试钱包私钥
RPC_URL=https://sepolia.infura.io/v3/你的key
只用测试钱包,别把主钱包私钥塞进项目。我当时第一次做测试网部署,图省事直接用了常用钱包,后来想起来都后怕。
第二步:添加部署脚本
// contracts/scripts/deploy.js
const hre = require("hardhat");
async function main() {
const [deployer, merchant] = await hre.ethers.getSigners();
console.log("Deployer:", deployer.address);
console.log("Merchant:", merchant.address);
const MockToken = await hre.ethers.getContractFactory("MockToken");
const token = await MockToken.deploy();
await token.waitForDeployment();
const decimals = await token.decimals();
const price = hre.ethers.parseUnits("10", decimals);
const TokenShop = await hre.ethers.getContractFactory("TokenShop");
const shop = await TokenShop.deploy(
await token.getAddress(),
merchant.address,
price
);
await shop.waitForDeployment();
console.log("MockToken:", await token.getAddress());
console.log("TokenShop:", await shop.getAddress());
console.log("Price:", price.toString());
// 给第三个账户一些测试代币
const accounts = await hre.ethers.getSigners();
if (accounts[2]) {
const user = accounts[2];
const mintAmount = hre.ethers.parseUnits("100", decimals);
const tx = await token.mint(user.address, mintAmount);
await tx.wait();
console.log(`Minted ${mintAmount} to ${user.address}`);
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
编译与部署
本地部署:
cd contracts
npx hardhat compile
npx hardhat run scripts/deploy.js
部署到 Sepolia:
npx hardhat run scripts/deploy.js --network sepolia
第三步:准备前端 ABI
从 artifacts 里复制两个 ABI 到前端项目,例如:
frontend/src/abi/MockToken.json
frontend/src/abi/TokenShop.json
只保留 abi 字段也行。
第四步:编写前端配置
// frontend/src/config.js
export const TOKEN_ADDRESS = "替换成你的 MockToken 地址";
export const SHOP_ADDRESS = "替换成你的 TokenShop 地址";
export const TARGET_CHAIN_ID = 11155111; // Sepolia
第五步:编写 React 页面
下面是一个简化但可运行的版本。核心能力包括:
- 连接钱包
- 检查链
- 查询余额
- 查询购买次数
- 检查 allowance
- 自动先授权再购买
// frontend/src/App.jsx
import { useEffect, useState } from "react";
import { ethers } from "ethers";
import tokenArtifact from "./abi/MockToken.json";
import shopArtifact from "./abi/TokenShop.json";
import { TOKEN_ADDRESS, SHOP_ADDRESS, TARGET_CHAIN_ID } from "./config";
function App() {
const [account, setAccount] = useState("");
const [provider, setProvider] = useState(null);
const [signer, setSigner] = useState(null);
const [tokenBalance, setTokenBalance] = useState("0");
const [purchaseCount, setPurchaseCount] = useState("0");
const [price, setPrice] = useState("0");
const [status, setStatus] = useState("未连接");
const connectWallet = async () => {
if (!window.ethereum) {
alert("请先安装 MetaMask");
return;
}
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const network = await browserProvider.getNetwork();
if (Number(network.chainId) !== TARGET_CHAIN_ID) {
setStatus("请切换到 Sepolia 网络");
return;
}
const accounts = await browserProvider.send("eth_requestAccounts", []);
const currentSigner = await browserProvider.getSigner();
setProvider(browserProvider);
setSigner(currentSigner);
setAccount(accounts[0]);
setStatus("钱包已连接");
};
const loadData = async (currentSigner, currentAccount) => {
if (!currentSigner || !currentAccount) return;
const token = new ethers.Contract(
TOKEN_ADDRESS,
tokenArtifact.abi,
currentSigner
);
const shop = new ethers.Contract(
SHOP_ADDRESS,
shopArtifact.abi,
currentSigner
);
const [balance, decimals, count, currentPrice] = await Promise.all([
token.balanceOf(currentAccount),
token.decimals(),
shop.getPurchaseCount(currentAccount),
shop.price()
]);
setTokenBalance(ethers.formatUnits(balance, decimals));
setPurchaseCount(count.toString());
setPrice(ethers.formatUnits(currentPrice, decimals));
};
const handleBuy = async () => {
try {
if (!signer || !account) {
alert("请先连接钱包");
return;
}
setStatus("准备购买...");
const token = new ethers.Contract(
TOKEN_ADDRESS,
tokenArtifact.abi,
signer
);
const shop = new ethers.Contract(
SHOP_ADDRESS,
shopArtifact.abi,
signer
);
const [currentPrice, allowance] = await Promise.all([
shop.price(),
token.allowance(account, SHOP_ADDRESS)
]);
if (allowance < currentPrice) {
setStatus("授权中...");
const approveTx = await token.approve(SHOP_ADDRESS, currentPrice);
await approveTx.wait();
}
setStatus("支付中...");
const buyTx = await shop.buy();
await buyTx.wait();
setStatus("购买成功");
await loadData(signer, account);
} catch (error) {
console.error(error);
setStatus(`失败: ${error.shortMessage || error.message}`);
}
};
useEffect(() => {
if (!window.ethereum) return;
const handleAccountsChanged = async (accounts) => {
if (!accounts.length) {
setAccount("");
setSigner(null);
setStatus("钱包已断开");
return;
}
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const currentSigner = await browserProvider.getSigner();
setProvider(browserProvider);
setSigner(currentSigner);
setAccount(accounts[0]);
};
const handleChainChanged = () => {
window.location.reload();
};
window.ethereum.on("accountsChanged", handleAccountsChanged);
window.ethereum.on("chainChanged", handleChainChanged);
return () => {
if (window.ethereum.removeListener) {
window.ethereum.removeListener("accountsChanged", handleAccountsChanged);
window.ethereum.removeListener("chainChanged", handleChainChanged);
}
};
}, []);
useEffect(() => {
if (signer && account) {
loadData(signer, account);
}
}, [signer, account]);
return (
<div style={{ maxWidth: 720, margin: "40px auto", fontFamily: "sans-serif" }}>
<h1>Token Pay DApp</h1>
<button onClick={connectWallet}>连接 MetaMask</button>
<div style={{ marginTop: 20 }}>
<p><strong>账户:</strong>{account || "未连接"}</p>
<p><strong>商品价格:</strong>{price} MUSD</p>
<p><strong>代币余额:</strong>{tokenBalance} MUSD</p>
<p><strong>购买次数:</strong>{purchaseCount}</p>
<p><strong>状态:</strong>{status}</p>
</div>
<button onClick={handleBuy} style={{ marginTop: 20 }}>
使用代币购买
</button>
</div>
);
}
export default App;
启动前端:
cd frontend
npm run dev
第六步:逐步验证清单
实际开发时,我很少一口气写完再跑,因为 Web3 项目出错面太多。更稳的方式是“一步一验”。
验证 1:钱包是否连接成功
检查点:
- MetaMask 已安装
- 前端点击后能弹出授权窗口
- 页面显示地址
验证 2:链是否正确
检查点:
- 当前网络为本地链或 Sepolia
chainId与前端配置一致
验证 3:余额是否正常读取
检查点:
- 代币地址正确
- ABI 正确
balanceOf能返回非 0 值
验证 4:授权是否成功
检查点:
- MetaMask 弹出一次授权交易
- 授权确认后
allowance(user, shop)大于等于price
验证 5:购买是否成功
检查点:
buy()成功上链- 商家地址代币余额增加
purchaseCount增加
用状态图理解购买流程
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Connected: connectWallet
Connected --> CheckingAllowance: clickBuy
CheckingAllowance --> Approving: allowance不足
CheckingAllowance --> Paying: allowance足够
Approving --> Paying: approve成功
Approving --> Connected: approve失败
Paying --> Purchased: buy成功
Paying --> Connected: buy失败
Purchased --> Connected: 刷新数据
常见坑与排查
这一节很重要。很多时候不是代码不会写,而是不知道错在哪。
1. transfer failed
常见原因:
- 用户没有足够余额
- 用户没有先
approve - 授权额度小于价格
- 商店合约传入的 token 地址不对
排查方式:
- 调用
balanceOf(user)看余额 - 调用
allowance(user, shop)看授权 - 调用
price()看价格 - 核对部署时的 token 地址
2. 前端连上钱包,但无法发送交易
常见原因:
- 使用了
provider读链,却没用signer写链 - 合约实例绑定错对象
- 当前链和部署链不一致
错误示例:
const contract = new ethers.Contract(address, abi, provider);
await contract.buy(); // 会失败,因为 provider 不能签名
正确写法:
const contract = new ethers.Contract(address, abi, signer);
await contract.buy();
3. MetaMask 切换账户后,页面还是旧地址
这是前端状态同步问题。
要监听:
accountsChangedchainChanged
我刚开始做 DApp 时,这个坑踩过很多次:页面上看着像 A 账户,实际发交易的是 B 账户,特别容易误判。
4. invalid BigNumberish value
常见原因:
- 字符串和数字混用
- 忘了
parseUnits - Ethers v5/v6 API 写法混了
例如价格是 10 个代币,不能直接用 10 去跟链上 uint256 比较。
正确方式:
const amount = ethers.parseUnits("10", 18);
展示时再格式化:
ethers.formatUnits(amount, 18);
5. 本地部署能用,测试网不行
常见原因:
- 没有给测试账户发代币
- 没有测试 ETH 支付 gas
- RPC 不稳定
- 合约地址复制错
- ABI 没同步更新
建议你养成一个习惯:每次重新部署,都同步更新前端地址和 ABI。
这是最基础但最容易忘的事。
安全/性能最佳实践
教程项目能跑起来不等于能上线。下面这部分是中级开发必须开始关注的。
1. 不要在生产环境开放 mint
我们示例里的 MockToken.mint() 没权限控制,是为了测试方便。
生产环境里至少要:
- 用
Ownable - 限制只有管理员可 mint
- 或者干脆固定总量,不开放增发
示例改造:
// 仅示意
import "@openzeppelin/contracts/access/Ownable.sol";
contract SecureToken is ERC20, Ownable {
constructor(address initialOwner) ERC20("SecureToken", "STK") Ownable(initialOwner) {}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
2. 授权额度不要默认无限大
很多前端为了减少一次授权操作,喜欢让用户 approve(maxUint256)。
这样做体验更顺,但风险也更高:
- 如果商店合约有漏洞,可能被持续扣款
- 如果合约升级或代理出问题,影响更大
更稳妥的做法:
- 按单次订单金额授权
- 或者按合理上限授权
- 给用户明确提示
3. 核心参数尽量 immutable
像:
- 支付代币地址
- 商家地址
- 固定价格
如果部署后不需要修改,建议放 immutable。
优点:
- gas 更低
- 逻辑更清晰
- 降低被误改风险
4. 重要操作加事件
我们在 buy() 里发了 Purchased 事件,这不是装饰品。它有实际价值:
- 前端更容易监听交易结果
- 方便区块浏览器检索
- 便于后端索引和统计
5. 读操作尽量批量并发
前端读取链上数据时,别一个个 await 串行执行。
差的写法:
const balance = await token.balanceOf(account);
const decimals = await token.decimals();
const count = await shop.getPurchaseCount(account);
更好的写法:
const [balance, decimals, count] = await Promise.all([
token.balanceOf(account),
token.decimals(),
shop.getPurchaseCount(account)
]);
这样页面会更顺一些,尤其 RPC 稍慢时差别明显。
6. 错误提示要面向用户,而不是只面向开发者
开发时我们喜欢直接打印原始异常,但真实用户看不懂。
建议区分两层:
- 控制台保留完整错误
- 页面展示简洁提示,比如“授权被取消”“余额不足”“网络错误”
7. 防止重复点击导致重复交易
购买按钮在交易 pending 期间应该禁用。
否则用户手快点两次,就可能发两笔交易。
一个简单做法:
const [loading, setLoading] = useState(false);
const handleBuy = async () => {
if (loading) return;
setLoading(true);
try {
// ...
} finally {
setLoading(false);
}
};
可进一步优化的方向
如果你准备把这个 DApp 做得更像个正式产品,可以继续扩展:
1. 增加签名登录
当前只是连接钱包。更完整的登录流程是:
- 后端生成 nonce
- 用户用钱包签名
- 后端验签并签发 session/JWT
这样可以把“地址拥有权证明”真正接进业务系统。
2. 支持 EIP-2612 Permit
如果代币支持 permit,可以把“授权”变成离线签名,减少一次链上 approve 交易。
用户体验会更好,gas 也更省。
3. 支持多商品和订单记录
可以把 buy() 扩展为:
function buy(uint256 productId, uint256 amount) external
然后记录订单结构体,而不仅仅是购买次数。
4. 接入事件监听或索引服务
前端可以直接读取事件,但生产项目通常还会配合:
- The Graph
- 自建索引器
- 后端消费事件
这样列表页和统计页性能更稳定。
小结:这类 DApp 的关键不是“会调用”,而是“会串起来”
我们这次完整走了一遍:
- 用 Solidity 写 ERC-20 与商店合约
- 用 Hardhat 编译和部署
- 用 Ethers.js 在前端连接 MetaMask
- 完成 授权 + 代币支付
- 加上状态管理、事件理解、错误排查和安全建议
如果你只记住一件事,我希望是这一句:
ERC-20 支付 DApp 的核心链路,不是单个函数,而是“连接钱包 → 校验网络 → 检查余额/授权 → 发起支付 → 等待确认 → 刷新状态”这整条闭环。
这是很多 Web3 项目从 demo 走向可用产品的分水岭。
总结
最后给你几个可执行建议,适合真正自己动手时照着做:
-
先在本地链跑通,再上测试网
不要一开始就把问题复杂化。 -
每完成一步就验证
钱包连接、余额读取、授权、购买,逐个确认。 -
把地址、ABI、链 ID 当成高频排查项
Web3 项目一半的问题都在这里。 -
测试环境可以简化,生产环境必须收紧权限
尤其是mint、授权额度、管理员权限这些点。 -
前端状态同步不能偷懒
监听账户和网络变化,是 DApp 可用性的基本盘。
如果你已经把本文项目跑起来了,说明你已经不只是“会写一个合约”或者“会连一个钱包”,而是开始具备搭建真实 Web3 业务最基础的整合能力了。接下来再往前一步,就可以尝试做订单系统、NFT 售卖、会员订阅,或者接入签名登录与后端服务。