Web3 实战:用 Solidity 与 Ethers.js 构建并部署一个支持角色权限控制的 DAO 治理合约
DAO 治理大家都听过,但一旦真的开始写合约,问题马上就来了:
- 谁能创建提案?
- 谁能执行提案?
- 普通成员能不能投票?
- 管理员权限是不是太大?
- 提案通过后,执行逻辑怎么保证不乱来?
如果这些问题没想清楚,DAO 很容易从“去中心化治理”变成“谁有权限谁说了算”。
这篇文章我不打算只讲概念,而是带你做一个能运行、能部署、能用 Ethers.js 交互的最小 DAO 治理系统。它具备两个关键能力:
- 角色权限控制:使用
AccessControl - 链上提案与投票执行:支持创建提案、投票、结束、执行
为了让示例更聚焦,我们实现的是一个 简化版 DAO 治理合约,适合学习核心机制,也方便你后续扩展为生产级系统。
背景与问题
很多初学者写 DAO 合约时,常见做法是这样:
- 用
owner控制全部管理功能 - 所有人都能提案,导致垃圾提案泛滥
- 投票结束条件不明确
- 执行阶段没有权限隔离
- 前端调用流程混乱,链上状态不好同步
这种写法的问题很明显:治理逻辑和管理逻辑耦合太重。
更合理的方式是把不同职责拆开:
ADMIN_ROLE:负责授予/撤销角色、管理系统参数PROPOSER_ROLE:负责创建提案EXECUTOR_ROLE:负责执行通过的提案- 普通成员:只负责投票
这样做的好处是,权限边界更清晰,也更贴近真实 DAO 系统的演进路径。
前置知识
如果你已经会下面这些内容,读起来会很顺:
- Solidity 基础语法
- Hardhat 基本使用
- Ethers.js 发交易、读合约状态
- 了解 ERC20 和基本链上治理概念
如果你还没系统做过,也没关系,本文会一步步带你跑起来。
环境准备
这里我用的是一套比较常见的组合:
- Node.js 18+
- Hardhat
- Solidity
^0.8.20 - OpenZeppelin Contracts
- Ethers.js v6
先初始化项目:
mkdir dao-governance-demo
cd dao-governance-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts ethers
npx hardhat
选择创建一个 JavaScript 项目。
项目结构大致如下:
dao-governance-demo/
├─ contracts/
├─ scripts/
├─ test/
├─ hardhat.config.js
└─ package.json
核心原理
我们先把设计讲清楚,再写代码会更自然。
1. 角色权限控制
这里用 OpenZeppelin 的 AccessControl,它比传统 Ownable 更适合 DAO 的权限分层。
角色定义:
DEFAULT_ADMIN_ROLE:超级管理员PROPOSER_ROLE:提案者EXECUTOR_ROLE:执行者
普通投票成员不需要单独角色,我们直接通过 addMember() 管理成员资格。
2. 提案生命周期
一个提案大致经历这些状态:
- 创建
- 投票中
- 投票结束
- 通过 / 拒绝
- 执行
我们会在合约里记录:
- 提案标题/描述
- 截止时间
- 赞成票 / 反对票
- 是否已执行
- 每个地址是否投过票
3. 最小可执行动作
为了避免“任意 call”带来的复杂性,这篇文章里的提案执行逻辑先做一个安全收敛版:
- 提案通过后,执行一个链上动作:更新 DAO 的
treasuryNote
这不是最强大的治理模型,但非常适合学习:你可以清楚看到提案 -> 投票 -> 执行状态变更的完整闭环。
DAO 治理流程图
flowchart TD
A[管理员初始化 DAO] --> B[添加成员]
B --> C[授权提案者]
C --> D[创建提案]
D --> E[成员投票]
E --> F{是否到截止时间}
F -- 否 --> E
F -- 是 --> G{赞成票 > 反对票?}
G -- 否 --> H[提案失败]
G -- 是 --> I[执行提案]
I --> J[更新链上状态]
合约结构图
classDiagram
class RoleBasedDAO {
+bytes32 PROPOSER_ROLE
+bytes32 EXECUTOR_ROLE
+string treasuryNote
+uint256 proposalCount
+addMember(address)
+removeMember(address)
+createProposal(string,string,string,uint256)
+vote(uint256,bool)
+executeProposal(uint256)
+getProposal(uint256)
}
class Proposal {
+uint256 id
+string title
+string description
+string newTreasuryNote
+uint256 deadline
+uint256 forVotes
+uint256 againstVotes
+bool executed
+bool exists
}
RoleBasedDAO --> Proposal
实战代码(可运行)
下面开始真正写代码。
第一步:编写 Solidity 合约
在 contracts/RoleBasedDAO.sol 中写入:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/AccessControl.sol";
contract RoleBasedDAO is AccessControl {
bytes32 public constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE");
bytes32 public constant EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE");
struct Proposal {
uint256 id;
string title;
string description;
string newTreasuryNote;
uint256 deadline;
uint256 forVotes;
uint256 againstVotes;
bool executed;
bool exists;
}
uint256 public proposalCount;
string public treasuryNote;
mapping(uint256 => Proposal) private proposals;
mapping(uint256 => mapping(address => bool)) public hasVoted;
mapping(address => bool) public members;
event MemberAdded(address indexed account);
event MemberRemoved(address indexed account);
event ProposalCreated(
uint256 indexed proposalId,
address indexed proposer,
string title,
uint256 deadline
);
event Voted(
uint256 indexed proposalId,
address indexed voter,
bool support,
uint256 weight
);
event ProposalExecuted(
uint256 indexed proposalId,
address indexed executor,
string newTreasuryNote
);
constructor(address admin, string memory initialTreasuryNote) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(PROPOSER_ROLE, admin);
_grantRole(EXECUTOR_ROLE, admin);
members[admin] = true;
treasuryNote = initialTreasuryNote;
}
modifier onlyMember() {
require(members[msg.sender], "Not a DAO member");
_;
}
function addMember(address account) external onlyRole(DEFAULT_ADMIN_ROLE) {
require(account != address(0), "Invalid account");
require(!members[account], "Already member");
members[account] = true;
emit MemberAdded(account);
}
function removeMember(address account) external onlyRole(DEFAULT_ADMIN_ROLE) {
require(members[account], "Not member");
members[account] = false;
emit MemberRemoved(account);
}
function createProposal(
string memory title,
string memory description,
string memory newTreasuryNote,
uint256 durationSeconds
) external onlyRole(PROPOSER_ROLE) returns (uint256) {
require(durationSeconds > 0, "Duration must be > 0");
proposalCount++;
proposals[proposalCount] = Proposal({
id: proposalCount,
title: title,
description: description,
newTreasuryNote: newTreasuryNote,
deadline: block.timestamp + durationSeconds,
forVotes: 0,
againstVotes: 0,
executed: false,
exists: true
});
emit ProposalCreated(
proposalCount,
msg.sender,
title,
block.timestamp + durationSeconds
);
return proposalCount;
}
function vote(uint256 proposalId, bool support) external onlyMember {
Proposal storage proposal = proposals[proposalId];
require(proposal.exists, "Proposal not found");
require(block.timestamp < proposal.deadline, "Voting ended");
require(!hasVoted[proposalId][msg.sender], "Already voted");
hasVoted[proposalId][msg.sender] = true;
if (support) {
proposal.forVotes += 1;
} else {
proposal.againstVotes += 1;
}
emit Voted(proposalId, msg.sender, support, 1);
}
function executeProposal(uint256 proposalId) external onlyRole(EXECUTOR_ROLE) {
Proposal storage proposal = proposals[proposalId];
require(proposal.exists, "Proposal not found");
require(block.timestamp >= proposal.deadline, "Voting not ended");
require(!proposal.executed, "Already executed");
require(proposal.forVotes > proposal.againstVotes, "Proposal not passed");
proposal.executed = true;
treasuryNote = proposal.newTreasuryNote;
emit ProposalExecuted(proposalId, msg.sender, proposal.newTreasuryNote);
}
function getProposal(uint256 proposalId) external view returns (Proposal memory) {
require(proposals[proposalId].exists, "Proposal not found");
return proposals[proposalId];
}
}
这份合约做了什么?
- 管理成员名单
- 管理提案者、执行者角色
- 允许提案者发起提案
- 允许成员投票
- 到期后由执行者执行通过的提案
- 执行结果会更新
treasuryNote
这里我特意没有把执行逻辑做成任意外部调用,因为教程阶段太容易把风险放大。先把治理流程打通,再做复杂执行器,是更稳的学习路径。
第二步:配置 Hardhat
编辑 hardhat.config.js:
require("@nomicfoundation/hardhat-toolbox");
module.exports = {
solidity: "0.8.20",
networks: {
hardhat: {},
},
};
编译:
npx hardhat compile
如果成功,你会看到编译输出。
第三步:编写部署脚本
在 scripts/deploy.js 中写入:
const { ethers } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying with:", deployer.address);
const DAO = await ethers.getContractFactory("RoleBasedDAO");
const dao = await DAO.deploy(deployer.address, "Initial treasury policy");
await dao.waitForDeployment();
const address = await dao.getAddress();
console.log("RoleBasedDAO deployed to:", address);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
部署到本地链:
npx hardhat node
另开一个终端:
npx hardhat run scripts/deploy.js --network localhost
第四步:用 Ethers.js 进行交互
为了模拟真实流程,我们写一个完整脚本:
- 管理员添加成员
- 管理员授予提案者/执行者角色
- 提案者创建提案
- 成员投票
- 时间推进
- 执行提案
- 查看最终结果
在 scripts/interact.js 中写入:
const { ethers } = require("hardhat");
async function main() {
const [admin, proposer, member1, member2, executor] = await ethers.getSigners();
const daoAddress = "替换成部署后的合约地址";
const dao = await ethers.getContractAt("RoleBasedDAO", daoAddress);
const PROPOSER_ROLE = ethers.keccak256(ethers.toUtf8Bytes("PROPOSER_ROLE"));
const EXECUTOR_ROLE = ethers.keccak256(ethers.toUtf8Bytes("EXECUTOR_ROLE"));
console.log("Admin:", admin.address);
console.log("Proposer:", proposer.address);
console.log("Member1:", member1.address);
console.log("Member2:", member2.address);
console.log("Executor:", executor.address);
// 添加成员
await (await dao.connect(admin).addMember(proposer.address)).wait();
await (await dao.connect(admin).addMember(member1.address)).wait();
await (await dao.connect(admin).addMember(member2.address)).wait();
await (await dao.connect(admin).addMember(executor.address)).wait();
// 授予角色
await (await dao.connect(admin).grantRole(PROPOSER_ROLE, proposer.address)).wait();
await (await dao.connect(admin).grantRole(EXECUTOR_ROLE, executor.address)).wait();
// 创建提案
const tx = await dao
.connect(proposer)
.createProposal(
"Update Treasury Note",
"Update treasury governance note",
"Treasury policy updated by DAO vote",
60
);
const receipt = await tx.wait();
const event = receipt.logs.find((log) => {
try {
const parsed = dao.interface.parseLog(log);
return parsed && parsed.name === "ProposalCreated";
} catch {
return false;
}
});
const parsed = dao.interface.parseLog(event);
const proposalId = parsed.args.proposalId;
console.log("Created proposal:", proposalId.toString());
// 投票
await (await dao.connect(member1).vote(proposalId, true)).wait();
await (await dao.connect(member2).vote(proposalId, true)).wait();
await (await dao.connect(admin).vote(proposalId, false)).wait();
let proposal = await dao.getProposal(proposalId);
console.log("For votes:", proposal.forVotes.toString());
console.log("Against votes:", proposal.againstVotes.toString());
// 推进时间
await ethers.provider.send("evm_increaseTime", [70]);
await ethers.provider.send("evm_mine");
// 执行提案
await (await dao.connect(executor).executeProposal(proposalId)).wait();
const note = await dao.treasuryNote();
console.log("Updated treasuryNote:", note);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
运行:
npx hardhat run scripts/interact.js --network localhost
如果一切正常,你会看到提案创建、投票、执行成功,最终 treasuryNote 被更新。
调用时序图
这个图有助于你把 Ethers.js 与合约之间的交互顺序串起来。
sequenceDiagram
participant Admin
participant Proposer
participant Member
participant Executor
participant DAO
Admin->>DAO: addMember()
Admin->>DAO: grantRole(PROPOSER_ROLE)
Admin->>DAO: grantRole(EXECUTOR_ROLE)
Proposer->>DAO: createProposal()
Member->>DAO: vote(true/false)
Member->>DAO: vote(true/false)
Executor->>DAO: executeProposal()
DAO-->>Executor: ProposalExecuted
第五步:逐步验证清单
我建议你不要一口气跑完,而是按下面顺序验证:
1. 部署后检查初始状态
const note = await dao.treasuryNote();
console.log(note);
预期输出:
Initial treasury policy
2. 检查管理员默认角色
const DEFAULT_ADMIN_ROLE = "0x0000000000000000000000000000000000000000000000000000000000000000";
console.log(await dao.hasRole(DEFAULT_ADMIN_ROLE, admin.address));
预期:
true
3. 未授权提案者创建提案应失败
如果你用普通成员直接调用 createProposal,应该 revert。
4. 非成员投票应失败
如果地址不在 members 里,vote() 会报错。
5. 重复投票应失败
同一地址第二次对同一个提案投票,应该 revert。
6. 提案结束前执行应失败
这一步非常关键,说明你的时间判断正常。
常见坑与排查
这一部分很重要。很多时候不是代码不会写,而是“明明看起来没问题,为什么跑不起来”。
1. AccessControl 权限报错
常见报错类似:
AccessControl: account xxx is missing role xxx
排查思路
- 调用者地址是不是你以为的那个地址?
- 是不是忘了
.connect(signer)? - 角色是不是授予成功了?
- 角色计算方式是否一致?
在 Ethers.js v6 里,角色计算可以这样写:
const PROPOSER_ROLE = ethers.keccak256(ethers.toUtf8Bytes("PROPOSER_ROLE"));
不要手写错字符串。我自己就踩过一次,把 PROPOSER_ROLE 写成 PROPOSER,查了半天。
2. 提案还没到期就执行
报错:
Voting not ended
原因通常是本地链时间没推进,或者推进了但没出块。
正确写法:
await ethers.provider.send("evm_increaseTime", [70]);
await ethers.provider.send("evm_mine");
注意:只加时间不挖块,状态未必会更新到你期望的区块。
3. 事件解析失败
如果你通过 receipt.logs 找事件,但解析报错,通常是:
- 合约实例 ABI 不对
- 不是当前合约发出的日志
- 没有逐条 try/catch 解析
本文里的写法已经做了容错:
const event = receipt.logs.find((log) => {
try {
const parsed = dao.interface.parseLog(log);
return parsed && parsed.name === "ProposalCreated";
} catch {
return false;
}
});
4. 本地部署地址和交互地址不一致
这是非常常见的问题:
- 你重新跑了部署脚本
- 但交互脚本里还是旧地址
建议做法:
- 把部署后的地址写入 JSON 文件
- 交互脚本直接读取
教程里为了直观先手动替换,真实项目请自动化。
5. 成员资格和角色权限混淆
这个示例里:
- 提案权限 由角色控制
- 投票权限 由成员资格控制
所以一个地址可能:
- 是成员,但不能提案
- 是提案者,但如果你没加为成员,也不能投票
- 是执行者,但不一定能提案
这不是 bug,而是设计选择。写前端时要把这层差异展示清楚。
安全/性能最佳实践
教程能跑通只是第一步,链上治理真正难的是安全边界。这里给你几个很实用的建议。
1. 不要轻易把执行逻辑做成任意外部调用
很多 DAO 教程会设计成:
- 提案里带
target - 带
calldata - 通过后任意执行
这很灵活,但风险也巨大:
- 可能调用恶意合约
- 可能重入
- 可能把资产一次性转走
- 审计复杂度直线上升
如果你是学习或做内部工具,建议先像本文这样,限制提案执行范围。
2. 引入投票门槛和法定人数
我们这里的通过条件只是:
proposal.forVotes > proposal.againstVotes
但在真实 DAO 里往往不够。你通常还需要:
- 最小参与人数(quorum)
- 最低赞成比例
- 提案冷却期
- 执行延迟(timelock)
例如:
- 至少 10 人参与
- 赞成票占比大于 60%
- 通过后 24 小时才能执行
这样能显著降低治理攻击风险。
3. 注意成员移除后的历史投票问题
当前实现中,如果一个成员在投票后被移除:
- 历史票数不会回滚
这是合理的,但你要明确规则。如果你要做“快照投票”,就应该把投票权和某个区块高度绑定,而不是简单看当前 members。
4. 控制链上存储成本
字符串写链上不便宜。本文为了易懂,把 title、description、newTreasuryNote 都直接存了。
生产里可以考虑:
- 链上存摘要
- 详细内容放 IPFS / Arweave
- 合约只存 CID 或哈希
这样 Gas 成本会明显下降。
5. 关键操作加事件
事件不是可有可无的“日志装饰品”,而是前端与索引服务的重要数据源。
本文里对这些动作都发了事件:
- 添加成员
- 移除成员
- 创建提案
- 投票
- 执行提案
如果你后续接前端,事件会非常好用。
6. 给角色管理增加多签或 Timelock
DEFAULT_ADMIN_ROLE 权限很大,所以别让它永远掌握在一个 EOA 手里。
更稳妥的做法是把管理员设为:
- 多签钱包
- Timelock 合约
- 另一个治理合约
这样能减少单点失误和私钥泄露风险。
可继续扩展的方向
如果你打算把这个 demo 往真实项目推进,我建议按下面顺序迭代:
- 加入 quorum
- 支持提案取消
- 支持投票权重
- 1 地址 1 票
- ERC20 持币权重
- ERC721/NFT 权重
- 执行层接 Timelock
- 提案内容上链摘要 + IPFS 明细
- 前端结合 The Graph 或事件索引
不要一开始就追求“全功能治理框架”,那样很容易把自己绕进去。先把最小治理闭环跑通,是更现实的路线。
一个更贴近实战的改造思路
如果你觉得“更新 treasuryNote 太简单”,可以把它扩展成 DAO Treasury 参数治理,例如:
- 修改金库提币上限
- 修改白名单地址
- 修改某个策略合约参数
- 开关某个功能模块
本质上都一样:提案最终映射为一组受控的链上状态变更。
换句话说,DAO 治理的重点不是“能执行任意东西”,而是“只执行被明确允许、被充分审计过的事情”。
总结
这篇文章我们完整实现了一个支持角色权限控制的 DAO 治理合约,并用 Ethers.js 跑通了全流程:
- 用
AccessControl做角色隔离 - 用成员名单控制投票资格
- 用提案结构管理治理流程
- 用 Ethers.js 完成部署、授权、提案、投票、执行
- 用本地链时间推进模拟治理周期
如果你现在准备自己动手,我建议按这个顺序做:
- 先把本文代码原样跑通
- 再尝试加入
quorum - 然后把执行逻辑改成“受限参数修改”
- 最后再考虑 Timelock、多签、代币化投票
边界条件也要记住:
- 这份代码适合教学和原型验证
- 不适合未经审计就直接上主网管理真实资产
- 一旦引入任意调用、资产转移、代币权重,安全复杂度会大幅提升
如果你能把这篇文章里的示例独立敲一遍、改一遍、再调通一遍,那你对 DAO 治理合约的理解,已经不只是“会看”,而是真的开始“会做”了。