1. 本节要解决的问题
Web3 DApp 的核心不是"前端页面能不能点",而是"链上合约是否正确保存状态、限制权限、托管资金并按规则释放资金"。
P01 的 BountyBoard.sol 是一个教学用悬赏任务合约。它支持:
- 发布者创建任务。
- 发布者给任务充值 ETH。
- 接单者提交交付内容。
- 发布者验收后把 ETH 支付给接单者。
- 发布者在任务未提交前取消任务并取回资金。
这份合约适合作为 Solidity 入门案例,因为它同时覆盖了智能合约开发中最核心的几类知识:
- 合约文件结构。
- 数据建模。
- 状态机。
- 权限检查。
- ETH 收款与付款。
- 事件日志。
- 自定义错误。
storage、memory、calldata的区别。- 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:用有限状态表达业务流程
BountyBoard 用 enum 描述任务状态:
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,所以前端可以通过 0 到 getTaskCount() - 1 读取任务。
8. storage、memory、calldata:数据位置决定读写语义
Solidity 里,复杂类型变量通常要考虑数据位置。
8.1 storage:链上持久化状态
solidity
Task storage task = _tasks[taskId];
storage 表示这个变量引用链上存储。修改 task.status、task.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.sender 和 msg.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);
}
执行顺序可以拆成五步:
- 检查任务说明不能为空。
- 使用
_nextTaskId分配任务 ID。 - 递增
_nextTaskId,避免下一条任务覆盖当前任务。 - 在
_tasks[taskId]中写入任务初始状态。 - 发出
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 权限必须在链上检查
不能依赖前端隐藏按钮来保护权限。攻击者可以绕过前端,直接调用合约。
所以 fundTask、acceptWork、cancelTask 都必须在合约里检查 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,要么通过事件索引或额外数组维护可枚举数据。