Solidity 合约基础:从 BountyBoard.sol 读懂链上任务状态机

1. 本节要解决的问题

Web3 DApp 的核心不是"前端页面能不能点",而是"链上合约是否正确保存状态、限制权限、托管资金并按规则释放资金"。

P01 的 BountyBoard.sol 是一个教学用悬赏任务合约。它支持:

  • 发布者创建任务。
  • 发布者给任务充值 ETH。
  • 接单者提交交付内容。
  • 发布者验收后把 ETH 支付给接单者。
  • 发布者在任务未提交前取消任务并取回资金。

这份合约适合作为 Solidity 入门案例,因为它同时覆盖了智能合约开发中最核心的几类知识:

  • 合约文件结构。
  • 数据建模。
  • 状态机。
  • 权限检查。
  • ETH 收款与付款。
  • 事件日志。
  • 自定义错误。
  • storagememorycalldata 的区别。
  • Checks-Effects-Interactions 安全模式。

2. 智能合约在 EVM 中是什么

在 EVM 里,智能合约是一段部署到链上的程序。部署完成后,它有自己的合约地址、代码和持久化存储。

可以把合约理解成"公开运行的链上后端":

  • 任何人都能读取合约公开状态。
  • 任何人都能向合约发交易,但合约可以在函数内部拒绝不合法调用。
  • 合约状态写入链上后,不能像传统数据库一样由管理员随意改历史。
  • 合约如果持有 ETH,资金释放规则必须写在代码里。

BountyBoard 的职责就是定义任务悬赏的链上规则:谁可以做什么、什么时候可以做、资金如何进入合约、什么时候离开合约。

3. 合约文件头:SPDX 与 pragma

BountyBoard.sol 的开头是:

solidity 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

SPDX-License-Identifier 用来声明源代码许可证。它不会改变合约执行逻辑,但会帮助编译器、区块浏览器和开源工具识别代码授权方式。

pragma solidity ^0.8.28; 用来声明合约期望的 Solidity 编译器版本范围。^0.8.28 表示允许使用 0.8.28 及以上、但仍属于 0.x 兼容范围内的编译器版本。

版本声明很重要,因为 Solidity 不同版本可能有语法、优化器或安全语义差异。比如 0.8.x 默认开启整数溢出检查,早期版本并不是这样。

4. contract:合约的边界

核心声明如下:

solidity 复制代码
contract BountyBoard {
    // 状态变量、事件、错误、函数都写在这里。
}

contract 定义一个可以被编译和部署的合约类型。部署后,链上会出现一个合约地址,用户或前端通过这个地址调用合约函数。

合约边界意味着:

  • 合约内部状态默认属于这个合约。
  • 外部账户不能直接修改状态变量,只能调用合约暴露的函数。
  • 合约函数必须自己做权限检查,前端检查不能替代链上检查。

5. enum:用有限状态表达业务流程

BountyBoardenum 描述任务状态:

solidity 复制代码
enum TaskStatus {
    Created,
    Funded,
    Submitted,
    Accepted,
    Cancelled
}

enum 适合表达"只能从几个固定值里选择一个"的状态。这里每个任务只能处在五种状态之一:

  • Created:任务已创建,但还没有充值。
  • Funded:任务已经充值,等待接单者提交。
  • Submitted:接单者已经提交,等待发布者验收。
  • Accepted:发布者已验收,悬赏已支付。
  • Cancelled:任务已取消,不能继续推进。

状态机是合约安全的基础。没有状态机,用户可能重复付款、提前验收、取消已完成任务,或者让业务流程进入混乱状态。

P01 的状态流可以简化为:

text 复制代码
Created -> Funded -> Submitted -> Accepted
   |          |
   v          v
Cancelled  Cancelled

6. struct:把一条任务的数据放在一起

任务数据结构如下:

solidity 复制代码
struct Task {
    address creator;
    address worker;
    uint256 amount;
    TaskStatus status;
    string metadataURI;
    string deliveryURI;
}

每个字段都有明确职责:

  • creator:任务发布者地址,决定谁有充值、验收、取消权限。
  • worker:提交交付内容的钱包地址。
  • amount:当前记录在任务里的悬赏金额,单位是 wei。
  • status:任务当前状态。
  • metadataURI:任务说明的离链地址。
  • deliveryURI:交付内容的离链地址。

这里用 URI 而不是直接把完整任务说明写上链,是因为链上存储很贵。生产项目通常会把长文本、图片、附件放到 IPFS、Arweave 或传统后端,只把内容地址或哈希写进合约。

7. mapping:用任务 ID 找到任务

合约用 mapping 保存任务:

solidity 复制代码
uint256 private _nextTaskId;
mapping(uint256 taskId => Task task) private _tasks;

_nextTaskId 表示下一个可用任务 ID。第一条任务 ID 是 0,创建后 _nextTaskId 变成 1

mapping(uint256 => Task) 可以理解成链上的键值表:

  • key 是 taskId
  • value 是 Task
  • 通过 _tasks[taskId] 读取某个任务。

初学者要注意:Solidity 的 mapping 不能直接遍历,也不能直接知道有哪些 key。P01 额外维护 _nextTaskId,所以前端可以通过 0getTaskCount() - 1 读取任务。

8. storage、memory、calldata:数据位置决定读写语义

Solidity 里,复杂类型变量通常要考虑数据位置。

8.1 storage:链上持久化状态

solidity 复制代码
Task storage task = _tasks[taskId];

storage 表示这个变量引用链上存储。修改 task.statustask.amount 等字段,会真正修改合约状态。

这很像拿到了数据库里某一行的引用,而不是复制了一份临时数据。

8.2 memory:内存中的临时副本

solidity 复制代码
function getTask(uint256 taskId) external view returns (Task memory task) {
    task = _getExistingTask(taskId);
}

memory 表示临时内存数据。getTask 返回的是任务数据副本,调用者可以读取它,但不能通过这个返回值直接修改链上任务。

8.3 calldata:外部调用传入的只读参数

solidity 复制代码
function createTask(string calldata metadataURI) external returns (uint256 taskId)

calldata 表示外部调用传进来的只读数据。对于 external 函数里的字符串、数组等参数,使用 calldata 通常更省 Gas,因为它不需要先复制到内存。

9. 函数可见性:external、private、view、payable

BountyBoard 里可以看到几种常见函数修饰:

solidity 复制代码
function createTask(string calldata metadataURI) external returns (uint256 taskId)
function fundTask(uint256 taskId) external payable
function getTaskCount() external view returns (uint256 taskCount)
function _requireCreator(uint256 taskId, Task storage task) private view

它们的含义是:

  • external:主要给外部账户、前端或其他合约调用。
  • private:只能在当前合约内部调用。
  • view:承诺不修改链上状态。
  • payable:允许函数接收 ETH。

fundTask 必须是 payable,否则用户无法通过 msg.value 把 ETH 发送给合约。

10. msg.sender 与 msg.value

在合约函数中,msg.sendermsg.value 是理解交易的关键。

solidity 复制代码
task.creator = msg.sender;
task.amount = msg.value;

msg.sender 是当前调用者地址。用户通过钱包发交易时,它通常就是用户钱包地址。

msg.value 是本次交易附带的 ETH 数量,单位是 wei。1 ETH = 10^18 wei

前端、脚本、测试都不能直接替合约"相信某个用户是谁"。合约必须用 msg.sender 自己判断权限。

11. 自定义错误:更清晰且更省 Gas 的失败信息

BountyBoard 使用自定义错误:

solidity 复制代码
error NotTaskCreator(uint256 taskId, address caller);
error InvalidTaskStatus(uint256 taskId, TaskStatus expected, TaskStatus actual);

使用时:

solidity 复制代码
if (msg.sender != task.creator) {
    revert NotTaskCreator(taskId, msg.sender);
}

相比 require(condition, "error message"),自定义错误通常更省 Gas,也能携带结构化参数。测试和前端可以根据错误名和参数判断失败原因。

对生产合约来说,清晰的错误类型很重要,因为它能让审计、测试和前端交互都更容易定位问题。

12. 事件:让链下系统知道链上发生了什么

合约事件示例:

solidity 复制代码
event TaskCreated(uint256 indexed taskId, address indexed creator, string metadataURI);
event TaskFunded(uint256 indexed taskId, address indexed funder, uint256 amount);
event WorkSubmitted(uint256 indexed taskId, address indexed worker, string deliveryURI);

事件写入交易日志。前端、区块浏览器、索引器可以监听这些日志,从而发现链上发生了哪些业务动作。

indexed 表示这个字段可以被日志查询更高效地过滤。例如前端可以按 creator 找某个地址创建过的任务。

但事件不是合约状态。合约内部不能遍历历史事件来恢复业务数据。关键业务状态仍然应该写在状态变量里,比如 _tasks

13. 创建任务的执行流程

createTask 的核心逻辑是:

solidity 复制代码
function createTask(string calldata metadataURI) external returns (uint256 taskId) {
    if (bytes(metadataURI).length == 0) {
        revert EmptyMetadataURI();
    }

    taskId = _nextTaskId;
    _nextTaskId += 1;

    Task storage task = _tasks[taskId];
    task.creator = msg.sender;
    task.worker = address(0);
    task.amount = 0;
    task.status = TaskStatus.Created;
    task.metadataURI = metadataURI;
    task.deliveryURI = "";

    emit TaskCreated(taskId, msg.sender, metadataURI);
}

执行顺序可以拆成五步:

  1. 检查任务说明不能为空。
  2. 使用 _nextTaskId 分配任务 ID。
  3. 递增 _nextTaskId,避免下一条任务覆盖当前任务。
  4. _tasks[taskId] 中写入任务初始状态。
  5. 发出 TaskCreated 事件。

这里的 address(0) 是零地址,常用来表示"还没有地址"。新任务还没有接单者,所以 worker 先设为零地址。

14. 充值任务:payable 与 ETH 托管

fundTask 负责把 ETH 锁进合约:

solidity 复制代码
function fundTask(uint256 taskId) external payable {
    Task storage task = _getExistingTask(taskId);
    _requireCreator(taskId, task);
    _requireStatus(taskId, task, TaskStatus.Created);

    if (msg.value == 0) {
        revert ZeroFunding();
    }

    task.amount = msg.value;
    task.status = TaskStatus.Funded;

    emit TaskFunded(taskId, msg.sender, msg.value);
}

这段逻辑保护了四个边界:

  • 任务必须存在。
  • 只有任务发布者能充值。
  • 任务必须处于 Created 状态。
  • 充值金额不能为 0

payable 函数收到 ETH 时,ETH 实际进入合约地址余额。task.amount 是合约内部记账,用来说明这笔资金属于哪条任务。

一个重要工程原则是:合约余额和内部记账要能互相解释。否则合约可能出现"链上有钱但不知道属于谁"或"记录里有钱但合约没有余额"的问题。

15. 提交任务:调用者身份与业务限制

submitWork 让接单者提交交付内容:

solidity 复制代码
function submitWork(uint256 taskId, string calldata deliveryURI) external {
    Task storage task = _getExistingTask(taskId);
    _requireStatus(taskId, task, TaskStatus.Funded);

    if (msg.sender == task.creator) {
        revert CreatorCannotSubmit(taskId, msg.sender);
    }

    if (bytes(deliveryURI).length == 0) {
        revert EmptyDeliveryURI();
    }

    task.worker = msg.sender;
    task.deliveryURI = deliveryURI;
    task.status = TaskStatus.Submitted;

    emit WorkSubmitted(taskId, msg.sender, deliveryURI);
}

这段逻辑体现了合约权限设计的一个原则:不要只判断"谁可以做",也要判断"谁不应该做"。

发布者不能提交自己的任务,因为悬赏场景默认是发布者付钱给外部接单者。虽然这是教学项目的简化规则,但它展示了链上业务约束应该写进合约,而不是只写在前端按钮逻辑里。

16. 验收任务:支付 ETH 与 CEI 模式

acceptWork 是本合约中最需要认真理解的函数,因为它会向外部地址发送 ETH。

solidity 复制代码
function acceptWork(uint256 taskId) external {
    Task storage task = _getExistingTask(taskId);
    _requireCreator(taskId, task);
    _requireStatus(taskId, task, TaskStatus.Submitted);

    address worker = task.worker;
    uint256 amount = task.amount;

    task.status = TaskStatus.Accepted;
    task.amount = 0;

    (bool success,) = payable(worker).call{value: amount}("");

    if (!success) {
        revert PaymentFailed(worker, amount);
    }

    emit WorkAccepted(taskId, worker, amount);
}

这段代码遵循 Checks-Effects-Interactions 模式:

  • Checks:先检查任务存在、调用者是发布者、状态是 Submitted
  • Effects:再修改合约内部状态,把任务标记为 Accepted,并把金额清零。
  • Interactions:最后才调用外部地址,发送 ETH。

为什么外部调用要放到最后?

因为向外部地址转账时,对方可能是一个合约。如果对方合约在收款时回调当前合约,错误的执行顺序可能导致重入攻击。先更新状态,再进行外部调用,可以显著降低重复提款风险。

call{value: amount}("") 是当前通用的 ETH 发送方式。它比 transfer 更灵活,但也要求开发者更重视重入风险和失败处理。

17. 取消任务:状态边界与退款

cancelTask 支持发布者取消还没有进入交付阶段的任务:

solidity 复制代码
function cancelTask(uint256 taskId) external {
    Task storage task = _getExistingTask(taskId);
    _requireCreator(taskId, task);

    if (task.status != TaskStatus.Created && task.status != TaskStatus.Funded) {
        revert TaskCannotBeCancelled(taskId, task.status);
    }

    uint256 refundAmount = task.amount;

    task.status = TaskStatus.Cancelled;
    task.amount = 0;

    if (refundAmount > 0) {
        (bool success,) = payable(task.creator).call{value: refundAmount}("");

        if (!success) {
            revert PaymentFailed(task.creator, refundAmount);
        }
    }

    emit TaskCancelled(taskId, task.creator, refundAmount);
}

这里允许取消两种状态:

  • Created:还没充值,取消时不需要退款。
  • Funded:已经充值但还没人提交,取消时需要退款。

这里不允许取消 Submitted,是为了保护接单者。否则接单者提交后,发布者可以直接取消并取回资金,这会破坏悬赏业务的公平性。

18. 内部函数:把重复检查集中起来

BountyBoard 把存在性检查、权限检查、状态检查拆成内部函数:

solidity 复制代码
function _getExistingTask(uint256 taskId) private view returns (Task storage task) {
    if (taskId >= _nextTaskId) {
        revert TaskNotFound(taskId);
    }

    task = _tasks[taskId];
}
solidity 复制代码
function _requireCreator(uint256 taskId, Task storage task) private view {
    if (msg.sender != task.creator) {
        revert NotTaskCreator(taskId, msg.sender);
    }
}
solidity 复制代码
function _requireStatus(uint256 taskId, Task storage task, TaskStatus expected) private view {
    if (task.status != expected) {
        revert InvalidTaskStatus(taskId, expected, task.status);
    }
}

这样做的好处是:

  • 业务函数更容易阅读。
  • 检查逻辑只有一份,后续修改不容易漏。
  • 测试失败时错误类型更统一。

但也要注意,抽内部函数不是越多越好。只有当逻辑确实重复、含义清晰、能提升可读性时,才值得抽出来。

19. 读取函数:view 不修改状态

合约提供两个读取函数:

solidity 复制代码
function getTaskCount() external view returns (uint256 taskCount) {
    taskCount = _nextTaskId;
}
solidity 复制代码
function getTask(uint256 taskId) external view returns (Task memory task) {
    task = _getExistingTask(taskId);
}

view 函数不修改链上状态。前端读取任务列表时,可以先读 getTaskCount(),再循环调用 getTask(id)

不过这只是教学项目的简单方案。真实项目如果任务很多,前端逐条读取会变慢,通常会结合事件索引器、The Graph、自建后端或分页读取方案。

20. 合约中的关键安全原则

20.1 权限必须在链上检查

不能依赖前端隐藏按钮来保护权限。攻击者可以绕过前端,直接调用合约。

所以 fundTaskacceptWorkcancelTask 都必须在合约里检查 msg.sender

20.2 状态必须在链上推进

每个函数只能在特定状态下执行。例如 acceptWork 只能验收 Submitted 状态的任务。

这能防止业务乱序:

  • 未充值不能提交。
  • 未提交不能验收。
  • 已提交不能取消。
  • 已完成不能重复付款。

20.3 外部调用前先更新状态

涉及 ETH 转账时,优先使用 Checks-Effects-Interactions:

text 复制代码
检查条件 -> 修改内部状态 -> 调用外部地址

这是 Solidity 安全开发中非常基础但非常重要的模式。

20.4 事件服务链下,状态服务链上

事件适合让前端和索引器追踪业务变化,但不能替代状态变量。核心业务判断必须依赖链上状态。

21. 常见误区

误区一:把合约当普通后端

普通后端可以改数据库、回滚数据、临时修线上问题。合约部署后,修改成本很高。即使后续可以用升级代理,复杂度和风险也会明显增加。

误区二:认为前端校验足够安全

前端只能改善用户体验,不能保护链上资产。任何关键限制都必须写进合约。

误区三:把大段文本直接写上链

链上存储成本很高。任务说明、交付材料、图片、附件应优先放链下,只把 URI 或哈希写上链。

误区四:随意调整 enum 顺序

enum 在底层会被编码成数字。已经部署并存储数据后,随意改变 enum 顺序会让旧数据含义错乱。

误区五:认为 call 自动安全

call 是推荐的通用转账方式,但它本身不保证业务安全。开发者仍然要处理失败返回值,并配合 CEI 模式或重入保护。

误区六:认为 mapping 可以直接列出所有任务

mapping 不可遍历。要么像 P01 一样维护递增 ID,要么通过事件索引或额外数组维护可枚举数据。

相关推荐
怒放de生命20101 天前
【web3基础】go-zero使用etcd实现服务注册与发现(四)
golang·web3·etcd·go-zero
怒放de生命20102 天前
【web3基础】go-zero环境搭建(一)
开发语言·后端·golang·web3·区块链
怒放de生命20102 天前
【web3基础】go-zero创建rpc服务,创建配置文件(二)
开发语言·rpc·golang·web3
木西6 天前
深度拆解 DeSci 龙头 OriginTrail:从核心架构到智能合约复刻与全链路测试
web3·智能合约·solidity
Web3李李6 天前
DApp核心安全漏洞与项目方全方位防护指南
web3·区块链·智能合约·软件开发·dapp开发·安全审计
加速财经7 天前
聚焦青年力量,MGBX打造全球Web3教育与创新平台
web3
Rockbean8 天前
10分钟-AI × Solana:2.MCP服务器——AI代理接入Solana的标准化通道
rust·web3·智能合约
Joker时代9 天前
重塑Web3安全防线:Vkey验证器正式登陆安卓与iOS平台,开启数字资产防护新纪元
安全·web3
木西11 天前
破局科研“死亡之谷”:基于 OpenZeppelin V5 与 Viem 深度复刻 VitaDAO 核心治理与资助系统
web3·智能合约·solidity