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

《Web3 实战:用 Solidity 与 Ethers.js 构建并部署一个支持角色权限控制的 DAO 治理合约》

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

Web3 实战:用 Solidity 与 Ethers.js 构建并部署一个支持角色权限控制的 DAO 治理合约

DAO 治理大家都听过,但一旦真的开始写合约,问题马上就来了:

  • 谁能创建提案?
  • 谁能执行提案?
  • 普通成员能不能投票?
  • 管理员权限是不是太大?
  • 提案通过后,执行逻辑怎么保证不乱来?

如果这些问题没想清楚,DAO 很容易从“去中心化治理”变成“谁有权限谁说了算”。

这篇文章我不打算只讲概念,而是带你做一个能运行、能部署、能用 Ethers.js 交互的最小 DAO 治理系统。它具备两个关键能力:

  1. 角色权限控制:使用 AccessControl
  2. 链上提案与投票执行:支持创建提案、投票、结束、执行

为了让示例更聚焦,我们实现的是一个 简化版 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. 提案生命周期

一个提案大致经历这些状态:

  1. 创建
  2. 投票中
  3. 投票结束
  4. 通过 / 拒绝
  5. 执行

我们会在合约里记录:

  • 提案标题/描述
  • 截止时间
  • 赞成票 / 反对票
  • 是否已执行
  • 每个地址是否投过票

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. 控制链上存储成本

字符串写链上不便宜。本文为了易懂,把 titledescriptionnewTreasuryNote 都直接存了。

生产里可以考虑:

  • 链上存摘要
  • 详细内容放 IPFS / Arweave
  • 合约只存 CID 或哈希

这样 Gas 成本会明显下降。


5. 关键操作加事件

事件不是可有可无的“日志装饰品”,而是前端与索引服务的重要数据源。

本文里对这些动作都发了事件:

  • 添加成员
  • 移除成员
  • 创建提案
  • 投票
  • 执行提案

如果你后续接前端,事件会非常好用。


6. 给角色管理增加多签或 Timelock

DEFAULT_ADMIN_ROLE 权限很大,所以别让它永远掌握在一个 EOA 手里。

更稳妥的做法是把管理员设为:

  • 多签钱包
  • Timelock 合约
  • 另一个治理合约

这样能减少单点失误和私钥泄露风险。


可继续扩展的方向

如果你打算把这个 demo 往真实项目推进,我建议按下面顺序迭代:

  1. 加入 quorum
  2. 支持提案取消
  3. 支持投票权重
    • 1 地址 1 票
    • ERC20 持币权重
    • ERC721/NFT 权重
  4. 执行层接 Timelock
  5. 提案内容上链摘要 + IPFS 明细
  6. 前端结合 The Graph 或事件索引

不要一开始就追求“全功能治理框架”,那样很容易把自己绕进去。先把最小治理闭环跑通,是更现实的路线。


一个更贴近实战的改造思路

如果你觉得“更新 treasuryNote 太简单”,可以把它扩展成 DAO Treasury 参数治理,例如:

  • 修改金库提币上限
  • 修改白名单地址
  • 修改某个策略合约参数
  • 开关某个功能模块

本质上都一样:提案最终映射为一组受控的链上状态变更

换句话说,DAO 治理的重点不是“能执行任意东西”,而是“只执行被明确允许、被充分审计过的事情”。


总结

这篇文章我们完整实现了一个支持角色权限控制的 DAO 治理合约,并用 Ethers.js 跑通了全流程:

  • AccessControl 做角色隔离
  • 用成员名单控制投票资格
  • 用提案结构管理治理流程
  • 用 Ethers.js 完成部署、授权、提案、投票、执行
  • 用本地链时间推进模拟治理周期

如果你现在准备自己动手,我建议按这个顺序做:

  1. 先把本文代码原样跑通
  2. 再尝试加入 quorum
  3. 然后把执行逻辑改成“受限参数修改”
  4. 最后再考虑 Timelock、多签、代币化投票

边界条件也要记住:

  • 这份代码适合教学和原型验证
  • 不适合未经审计就直接上主网管理真实资产
  • 一旦引入任意调用、资产转移、代币权重,安全复杂度会大幅提升

如果你能把这篇文章里的示例独立敲一遍、改一遍、再调通一遍,那你对 DAO 治理合约的理解,已经不只是“会看”,而是真的开始“会做”了。


分享到:

上一篇
《微服务架构中基于服务网格的灰度发布与流量治理实战》
下一篇
《Docker 多阶段构建与镜像瘦身实战:为中型项目建立高效、可维护的生产镜像》