做去中心化应用(DApp)最容易踩的坑,不是合约逻辑写错,而是开发框架、合约库、浏览器钱包这三样东西各管一摊,拼起来的时候总有细节对不上。这篇要聊的Demo,就是基于 Hardhat + OpenZeppelin + MetaMask 钱包 跑通的一个极简去中心化应用。它不是一个花哨的产品,只是一个网页:连接钱包、读余额、铸造、转账,合约跑在本地 Hardhat 网络上,合约本身直接复用 OpenZeppelin 的标准 ERC20 组件。适合刚接触 DApp 开发、想把“合约编译、部署、钱包签名、前端调用”这条链路完整串起来的人。把这个最小闭环跑熟,后面再做 NFT、投票、游戏资产这类应用,替换合约和 ABI 就行。
1. 先拆清楚:这个Demo的架构是怎么分层的
很多新手上来就写合约,结果卡在“为什么 MetaMask 连不上”“为什么页面调不到合约方法”。其实 DApp 的开发本质是让三个角色协作:链上合约、本地/远程节点、浏览器钱包。这个 Demo 恰好把这三块都串起来了。
1.1 Hardhat 负责什么,为什么不是 Truffle
Hardhat 在这个项目里承担的是“开发工作台”角色。它管四件事:合约编译、合约部署、运行一个本地以太坊节点、跑自动化测试。你可以把它理解成一个专门为合约开发准备的本地服务器加构建工具,只是它输出的不是网页,而是字节码和部署好的合约实例。
我选择 Hardhat 而不是其他框架,主要原因是它对浏览器的调试链路更顺。Hardhat 启动的本地节点默认带一堆有余额的测试账户,每个账户的私钥会直接打印在终端里,这对接 MetaMask 非常方便。你只需要把这些私钥导入 MetaMask,就能立刻模拟真实用户和合约交互。另一个原因是 Hardhat 支持在合约里打 console.log,调试合约状态比“猜”要高效得多。Truffle 当然也能做,但 Hardhat 的插件生态和文档现在更活跃,新项目用 Hardhat 踩坑成本低很多。
这个 Demo 里,Hardhat 还负责一件事:提供可以重复重置的本地环境。你随时可以按 Ctrl+C 停掉本地节点再重新启动,所有合约状态归零。这在开发阶段非常重要,因为你不想因为一次错误转账就永久污染测试数据。
1.2 OpenZeppelin 解决什么问题,MetaMask 又解决什么问题
OpenZeppelin 是一个现成的合约组件库,专门解决“标准合约安全实现”的问题。以 ERC20 为例,如果自己手写 transfer、approve、transferFrom 这些逻辑,很容易在边界条件上出 bug,比如没有检查调用者余额、没有触发 Transfer 事件、approve 额度覆盖行为不符合主流标准。OpenZeppelin 把这些已经经过大量项目验证的代码封装成可继承的合约,我们只需要专注业务字段和权限规则。
MetaMask 则解决“身份与签名”的问题。它本质是一个浏览器钱包,私钥存在用户本地的插件环境里,网页 JavaScript 永远拿不到私钥。当用户点击“转账”按钮时,前端只是构造好一笔交易请求,交给 MetaMask 弹窗让用户确认签名。签名通过后,这笔交易才广播到 Hardhat 节点。这个流程才是“去中心化”的关键:资产和私钥不在服务器上,而由用户自己控制。
1.3 Demo 的功能边界
这个 Demo 刻意把功能收敛到最小范围,只做四件事:
- 页面能检测到浏览器安装了 MetaMask,并请求连接钱包
- 连接后显示当前账户地址
- 读取当前账户的 DemoToken 余额
- 点击按钮可以铸造 100 个 DemoToken 给自己,也可以向任意地址转账
为什么不做登录、后端、数据库?因为 DApp 的核心价值是“链上状态可验证”。前端只是一个查看和操作界面,任何状态变更最终都要变成合约交易。把这三个操作跑通,你已经理解了 DApp 的完整骨架。这个 DemoToken 没有任何实际价值,只是为了演示合约调用链路,不要把它当生产级代币合约用。
2. 从空目录到能编译:Hardhat 工程搭建与配置
这个部分我会按实际操作的顺序来写。每一步都给出了命令,但更重要的是要理解为什么这样配。
2.1 环境要求与初始化命令
先确保本机装了 Node.js,建议版本在 18 以上,因为新版 Hardhat 和 ethers.js 都依赖较新的 Node 特性。然后新建一个空目录:
mkdir dapp-demo cd dapp-demo npm init -y npm install --save-dev hardhat安装完成后执行:
npx hardhat init初始化向导会问你要创建什么项目,我建议选 JavaScript。虽然 TypeScript 对长期项目更友好,但这个 Demo 的核心是跑通链路,JavaScript 能少一层类型配置,读起来也更直观。向导还会问是否安装示例项目,可以直接接受,后面我们会把示例合约替换掉。
接下来安装两个关键依赖:
npm install --save-dev @nomicfoundation/hardhat-toolbox @openzeppelin/contractshardhat-toolbox是 Hardhat 官方推荐的测试工具包,里面已经集成了 ethers.js、chai、合约覆盖率等常用能力。@openzeppelin/contracts就是前面说的合约组件库,之后我们在合约里直接 import 它。
2.2 hardhat.config.js 的关键配置项
初始化完成后,工程里会有一个hardhat.config.js。默认配置可以跑,但为了和 MetaMask 对接时少出问题,我建议至少显式设置本地网络的 chainId。
require("@nomicfoundation/hardhat-toolbox"); module.exports = { solidity: "0.8.24", networks: { hardhat: { chainId: 31337 }, localhost: { url: "http://127.0.0.1:8545", chainId: 31337 } } };这里有两个网络,名字容易混。
hardhat是测试和部署脚本默认使用的临时网络,每次跑命令都是全新的链上状态。localhost是连接你已经启动的本地节点的网络,需要先手动执行npx hardhat node才能用。
把 chainId 固定成 31337 非常关键。MetaMask 在添加一个自定义网络时,会拿着 chainId 去和节点返回的 chainId 做比对,如果不一致,就会报“Chain ID mismatch”。虽然 31337 是 Hardhat 默认值,但显式写出来能提醒你:前端和钱包里配置的 31337,必须和节点一致。
2.3 工程结构里哪些目录是核心
初始化完成后,主要关注这几个路径:
contracts/:智能合约源码目录scripts/:部署脚本目录test/:自动化测试目录artifacts/:编译产物,包含 ABI,前端要用的接口定义就在这里
很多新手会把 ABI 抄来抄去,其实不用。每次npx hardhat compile之后,artifacts/contracts/下面会自动生成 JSON 文件,里面就有合约 ABI。后面写前端的时候,我们既可以引整个 JSON,也可以为了演示只摘出用到的方法定义。
3. 写合约:用 OpenZeppelin 快速实现带铸造功能的 ERC20
现在进入合约部分。这个 Demo 选 ERC20 作为业务合约,是因为它足够标准,又能清楚展示 OpenZeppelin 的继承优势。
3.1 合约代码逐段解读
在contracts/下新建一个文件DemoToken.sol,写入:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; contract DemoToken is ERC20, Ownable { constructor() ERC20("DemoToken", "DMO") Ownable(msg.sender) {} function mint(address to, uint256 amount) external onlyOwner { _mint(to, amount); } function burn(uint256 amount) external { _burn(msg.sender, amount); } }第一眼可能觉得代码太少,但这恰恰是 OpenZeppelin 的价值。ERC20这个父合约已经实现了balanceOf、transfer、approve、transferFrom、totalSupply等标准方法。我们自己的DemoToken只需要做两件事:在构造函数里设置代币名称和符号,再加一个只有合约 owner 能调用的mint。
需要注意,OpenZeppelin 5.x 版本的Ownable构造函数需要传入初始 owner,所以写的是Ownable(msg.sender)。如果你在网上看到的教程是Ownable(),那是旧版本写法,直接复制会造成编译错误。
mint方法前的onlyOwner是修饰器,它保证了只有部署合约的账户能铸造新代币。_mint是 OpenZeppelin 内部方法,会自动增加总供应量和目标地址余额,同时触发Transfer事件。至于burn,我故意留了一个让任意用户销毁自己代币的公开方法,方便测试“余额减少”的场景。
3.2 为什么不用自己写 transfer
自己写一个能转账的合约看起来很简单,比如:
function transfer(address to, uint256 amount) public { balances[msg.sender] -= amount; balances[to] += amount; }但真实 ERC20 要考虑的问题远不止这些:余额不足时应该回滚而非静默扣成负数;转账必须触发Transfer事件,方便链下索引器追踪;approve/transferFrom的授权关系要处理;decimals默认是 18 还是自定义;代币转移给零地址是否应该拒绝。这些细节每一个都可能导致资产损失或者与主流钱包不兼容。
用 OpenZeppelin 的标准实现,等于把这些踩过的坑全部避开。这个 Demo 不代表“合约没问题就能上线”,但它至少能让你明确一点:标准功能不要重复造轮子,你的注意力应该放在业务逻辑和权限边界上。
3.3 单元测试先跑通再部署
合约写完后不要急着部署,先写一个最小测试。在test/目录下创建DemoToken.js:
const { expect } = require("chai"); describe("DemoToken", function () { it("owner 可以铸造代币", async function () { const [owner, user] = await ethers.getSigners(); const DemoToken = await ethers.getContractFactory("DemoToken"); const token = await DemoToken.deploy(); await token.waitForDeployment(); await token.mint(user.address, ethers.parseEther("100")); expect(await token.balanceOf(user.address)).to.equal(ethers.parseEther("100")); }); it("非 owner 不能铸造代币", async function () { const [owner, user] = await ethers.getSigners(); const DemoToken = await ethers.getContractFactory("DemoToken"); const token = await DemoToken.deploy(); await expect( token.connect(user).mint(user.address, ethers.parseEther("1")) ).to.be.reverted; }); });执行:
npx hardhat test第二个测试我故意没有断言具体的报错文案,因为 OpenZeppelin 5.x 用的是自定义错误类型,不同版本提示不同。只要看到“reverted”就算通过。这一步的目的是建立信心:合约编译没问题,权限控制也在工作。测试通过后再进部署,后面遇到问题你会更容易定位是合约问题还是前端问题。
4. 把 MetaMask 钱包接进前端页面
合约在链上跑起来之后,用户是不能直接读合约状态的。我们需要一个前端页面,通过 MetaMask 注入的 provider 去连接钱包、读取链上数据、发起交易。
4.1 页面结构与 ethers.js 引入方式
这个 Demo 我不引入框架,就用一个原生 HTML 文件加 ethers.js 的 CDN,方便你直接打开看逻辑。页面核心结构如下:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>DApp Demo</title> <script src="https://cdn.jsdelivr.net/npm/ethers@6.13.4/dist/ethers.umd.min.js"></script> </head> <body> <button id="connectBtn">连接钱包</button> <div id="account"></div> <button id="mintBtn">给自己铸造 100 DMO</button> <input id="toAddress" placeholder="接收地址" /> <input id="amount" placeholder="转账数量" /> <button id="transferBtn">转账</button> <div id="status"></div> <script> // 后面所有 JavaScript 都写在这里 </script> </body> </html>如果你用的是 React 或 Vue,流程完全一样,只是把操作 DOM 换成框架语法。核心只有一个:ethers这个全局对象。
4.2 连接钱包与切换网络
连接钱包的第一步是检查window.ethereum。MetaMask 安装后会在每个页面注入这个对象,页面逻辑必须判断它是否存在。
const CONTRACT_ADDRESS = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; const ABI = [ "function mint(address to, uint256 amount)", "function balanceOf(address account) view returns (uint256)", "function transfer(address to, uint256 amount) returns (bool)", "function symbol() view returns (string)", "function decimals() view returns (uint8)" ]; let provider, signer, tokenContract; async function connect() { if (!window.ethereum) { alert("请安装 MetaMask"); return; } await window.ethereum.request({ method: "eth_requestAccounts" }); provider = new ethers.BrowserProvider(window.ethereum); signer = await provider.getSigner(); const address = await signer.getAddress(); document.getElementById("account").innerText = address; await addHardhatNetwork(); tokenContract = new ethers.Contract(CONTRACT_ADDRESS, ABI, signer); }eth_requestAccounts会触发 MetaMask 的授权弹窗。用户点击确认后,页面只能拿到账户地址,拿不到私钥。这一步是整个 DApp 的安全基石:私钥永远留在 MetaMask 内部,前端只是在用户签名后广播交易。
接着要处理网络。MetaMask 默认连的是以太坊主网或者其他公共测试网,不会自动知道自己应该连本地 Hardhat 节点。所以我们得主动加网络:
async function addHardhatNetwork() { const network = { chainId: "0x7a69", chainName: "Hardhat Local", nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 }, rpcUrls: ["http://127.0.0.1:8545"] }; try { await window.ethereum.request({ method: "wallet_switchEthereumChain", params: [{ chainId: network.chainId }] }); } catch (error) { if (error.code === 4902) { await window.ethereum.request({ method: "wallet_addEthereumChain", params: [network] }); } else { throw error; } } }这里的 chainId 是字符串形式的 16 进制。31337 转成 16 进制是 0x7a69。很多前端代码在这里出错,是因为直接用十进制数字传给了 MetaMask,MetaMask 只认带 0x 的十六进制字符串。
RPC 地址我特意写了http://127.0.0.1:8545,而不是http://localhost:8545。在部分机器上,localhost会被解析成 IPv6 地址::1,而 Hardhat 默认只监听 IPv4 的 127.0.0.1,导致 MetaMask 报网络连接失败。这类问题非常隐蔽,直接避开最省心。
4.3 合约调用:读余额、铸造、转账
连接完成后,就可以通过合约实例做操作了。读余额是只读调用,不需要用户确认:
async function getBalance(address) { const balance = await tokenContract.balanceOf(address); return ethers.formatEther(balance); }balanceOf返回的是最小单位,对 18 位小数的代币来说,返回值会比我们看到的大 10 的 18 次方倍。所以展示给用户前必须用formatEther转回正常数字,否则页面上可能显示一长串,看着像是余额爆炸。
铸造和转账是状态变更操作,需要走完整签名流程:
async function mint() { const address = await signer.getAddress(); const tx = await tokenContract.mint(address, ethers.parseEther("100")); await tx.wait(); document.getElementById("status").innerText = "铸造成功"; refreshBalance(); } async function transfer() { const to = document.getElementById("toAddress").value.trim(); const amount = document.getElementById("amount").value; const tx = await tokenContract.transfer(to, ethers.parseEther(amount)); await tx.wait(); document.getElementById("status").innerText = "转账成功"; refreshBalance(); }写合约调用时,最容易犯的错误是把字符串数字直接传给合约方法,例如tokenContract.transfer(to, "100")。这样会被当作 100 wei,而不是 100 个代币。必须先ethers.parseEther("100"),把“人类可读的 100 个代币”转换成链上最小单位。
tx.wait()不能省。它表示等待交易被区块确认。在本地 Hardhat 节点上,出块速度非常快,但如果没有wait,页面可能会在交易还没上链时就刷新余额,造成“转账后余额没变化”的错觉。
4.4 事件与刷新后的状态恢复
实际使用中,用户可能点击浏览器刷新。这时页面里的provider、signer、contract全部丢了,不能要求用户再重新走一遍完整授权流程吗?其实不用。
可以在页面加载时先尝试读取本地已经连接过的账户:
window.addEventListener("load", async () => { if (window.ethereum && window.ethereum.selectedAddress) { await connect(); } });selectedAddress是 MetaMask 注入的一个字段,表示当前已选中的钱包地址。通过它,我们可以判断用户是否已经授权过。如果存在,就自动恢复连接。
另外要监听账户切换。用户可能在 MetaMask 里切换到另一个账户,此时如果页面还显示旧账户,状态就会错乱。需要监听accountsChanged事件:
window.ethereum.on("accountsChanged", (accounts) => { if (accounts.length > 0) { document.getElementById("account").innerText = accounts[0]; // 重新生成 signer 和 contract } else { document.getElementById("account").innerText = "未连接"; } });这是很多 demo 教程不讲的细节,但实际体验影响很大。带上这两个功能,页面才像一个“能正常使用”的 DApp,而不是一次性的演示脚本。
5. 本地部署联调流程与高频问题排查
前端写完了,最后一步是把整条链路连起来跑。这个过程有几个固定顺序,建议按顺序执行,能省很多排查时间。
5.1 deploy 脚本怎么写、怎么跑
部署脚本的作用是把合约从一个可编译的抽象概念变成一个真实存在于链上的实例。在scripts/下创建deploy.js:
const { ethers } = require("hardhat"); async function main() { const [deployer] = await ethers.getSigners(); console.log("Deployer:", deployer.address); const DemoToken = await ethers.getContractFactory("DemoToken"); const token = await DemoToken.deploy(); await token.waitForDeployment(); const address = await token.getAddress(); console.log("DemoToken deployed to:", address); } main().catch((error) => { console.error(error); process.exitCode = 1; });执行部署有两种方式。
第一种,直接跑:
npx hardhat run scripts/deploy.js这种方式使用的是 Hardhat 内置的临时网络,部署完合约就没了,只适合快速验证脚本有没有问题。
第二种,先启动一个持续运行的本地节点:
npx hardhat node这会启动一个监听127.0.0.1:8545的节点,终端会打印出一大堆测试账户地址和私钥。这个节点不会自动退出,合约状态会一直保留,直到你手动停止。然后在另一个终端里执行:
npx hardhat run scripts/deploy.js --network localhost部署完成后,终端会打印合约地址。这个地址要填到前端页面的CONTRACT_ADDRESS常量里。很多人忽略这一步,前端拿到的是上一个部署周期的旧地址,结果合约实例指向的链上代码根本不对,调用方法时不是报错就是拿到了空的余额。
5.2 Hardhat 本地账户如何导入 MetaMask
本地节点启动后,终端会输出以Account #0开头的列表,每一行后面都有Private Key:。这个私钥就是对应账户的私钥,只用于本地开发。
在 MetaMask 里,点击右上角账户头像,选择“添加账户”,然后选择“导入账户”,把终端里Account #0的私钥粘贴进去。导入成功后,MetaMask 里就会出现一个拥有大量测试 ETH 的账户,可以用它来支付交易的 gas 费用。
这里必须强调一下:这些私钥是 Hardhat 在开发环境里固定生成的,常年在各种教程里出现,属于公开测试私钥,永远不要在主网或任何真实资产环境里使用。导入后如果误以为这些 ETH 有价值,很容易产生误解。这个 Demo 里所有代币和 ETH 都只是本地模拟数据。
导入账户后,还要确认 MetaMask 当前选中的网络是前面添加的 Hardhat Local。如果显示的是其他网络,交易会被广播到错误的地方,前端会一直处于等待状态。
5.3 高频报错与排查速查表
联调过程中最花时间的往往不是业务逻辑,而是环境配置。把下面这几张表收藏一下,会比每次临时百度高效得多。
| 现象 | 大概率原因 | 处理办法 |
|---|---|---|
| MetaMask 报 Chain ID mismatch | hardhat.config.js 里的 chainId 和钱包里配置的网络 chainId 不一致 | 统一使用 31337,钱包网络配置里 chainId 写 0x7a69 |
| 页面一直连不上节点 | RPC 地址写了 localhost 或端口不对 | 改成 http://127.0.0.1:8545,确认 hardhat node 已启动 |
| 点击连接没反应 | MetaMask 没有安装,或者页面没有检测到 window.ethereum | 先安装 MetaMask,刷新页面;检查是否用了隐私模式拦截 |
| 铸造按钮报 missing revert data | mint 不是 owner 调用,或合约地址不对 | 确认当前钱包是部署合约的账户,确认 CONTRACT_ADDRESS 是最新部署地址 |
| 转账后余额不变 | 没有等待 tx.wait(),或者前端误把字符串当最小单位 | 调用后等待交易确认,使用 parseEther 转换数量 |
| 本地节点重启后 MetaMask 报 nonce 过高 | MetaMask 还保留着旧链上的交易记录 | 在 MetaMask 设置里清理活动记录,或者换一个新导入的本地账户 |
| 页面刷新后连接状态丢失 | 没有用 selectedAddress 自动恢复,也没有监听 accountsChanged | 页面加载时判断 window.ethereum.selectedAddress,自动调用 connect |
| 合约编译时报 Ownable 构造函数错误 | 使用了 OpenZeppelin 旧版本教程,构造函数传参方式不同 | 按当前 5.x 版本写 Ownable(msg.sender),并确认依赖版本 |
最后分享一个个人习惯。每次重启 Hardhat 节点之后,我都会先在 MetaMask 里把当前网络切到别处,再切回 Hardhat Local。这个看起来多余的动作,能清掉很多因为本地节点状态重置而残留的缓存问题。遇到过 “nonce too low” 或者交易长时间 pending 的时候,不要对着代码发愁,先做这一步再继续排查。开发 DApp 的日常就是靠这些不起眼的小习惯把时间省下来的。