跳转到内容
123xiao | 无名键客

《Web3 中级实战:用 Solidity + Ethers.js 构建并部署一个支持 MetaMask 登录与代币支付的 DApp》

字数: 0 阅读时长: 1 分钟

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 标准要求:

  1. 用户先对商店合约执行 approve(spender, amount)
  2. 商店合约再调用 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 地址不对

排查方式:

  1. 调用 balanceOf(user) 看余额
  2. 调用 allowance(user, shop) 看授权
  3. 调用 price() 看价格
  4. 核对部署时的 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 切换账户后,页面还是旧地址

这是前端状态同步问题。
要监听:

  • accountsChanged
  • chainChanged

我刚开始做 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 走向可用产品的分水岭。


总结

最后给你几个可执行建议,适合真正自己动手时照着做:

  1. 先在本地链跑通,再上测试网
    不要一开始就把问题复杂化。

  2. 每完成一步就验证
    钱包连接、余额读取、授权、购买,逐个确认。

  3. 把地址、ABI、链 ID 当成高频排查项
    Web3 项目一半的问题都在这里。

  4. 测试环境可以简化,生产环境必须收紧权限
    尤其是 mint、授权额度、管理员权限这些点。

  5. 前端状态同步不能偷懒
    监听账户和网络变化,是 DApp 可用性的基本盘。

如果你已经把本文项目跑起来了,说明你已经不只是“会写一个合约”或者“会连一个钱包”,而是开始具备搭建真实 Web3 业务最基础的整合能力了。接下来再往前一步,就可以尝试做订单系统、NFT 售卖、会员订阅,或者接入签名登录与后端服务。


分享到:

上一篇
《Spring Boot 中基于 JWT 与 Spring Security 的前后端分离登录鉴权实战与权限设计》
下一篇
《中级开发者如何构建基于大模型的企业知识库问答系统:从RAG检索增强到效果评测实践》