news 2026/10/10 16:37:08

用 Solidity 写一个待办事项合约:从需求到代码的完整思考过程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用 Solidity 写一个待办事项合约:从需求到代码的完整思考过程

上一篇我留了一道自测题:写一个管理"待办事项列表"的合约,支持添加、完成、删除、查询,每个待办有创建时间戳和完成状态,只有创建者能操作自己的待办。

这一篇就是这道题的完整解答。但我不想只给你一份能跑的代码——我想把写这份代码时的每一个决策、每一个"为什么不用那种写法"都摊开来讲。因为真实的合约开发就是这样:写代码只占三成时间,另外七成在想"这样写安不安全、贵不贵、别人能不能钻空子"。

先把需求翻译成数据结构

需求里有几个关键词:待办事项、创建者、创建时间戳、完成状态、增删改查。

第一步不是写函数,是想数据怎么存。这里有个关键判断:一个用户会有多个待办,所以需要"从地址到待办列表"的映射。

struct Todo { string content; uint256 createdAt; bool completed; } mapping(address => Todo[]) private todos;

Todo结构体封装了一个待办的三个属性。mapping(address => Todo[])让每个地址拥有一个独立的待办数组。

为什么用数组而不是 mapping?因为待办事项需要"枚举"——用户要能看到自己所有的待办。如果用mapping(uint256 => Todo),你没法遍历它。数组的遍历成本在用户待办数量可控的前提下是可以接受的,这是"功能正确性"优先于"Gas 极致优化"的合理取舍。

为什么 content 用string?待办内容是文本,string最直观。但要注意:链上存字符串很贵,如果内容很长,更好的做法是把内容哈希或 IPFS 地址存链上。教学场景下,string够了。

完整代码

// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract TodoList { struct Todo { string content; uint256 createdAt; bool completed; } mapping(address => Todo[]) private todos; event TodoAdded(address indexed owner, uint256 indexed index, string content); event TodoCompleted(address indexed owner, uint256 indexed index); event TodoDeleted(address indexed owner, uint256 indexed index); error EmptyContent(); error TodoNotFound(uint256 index); error NotOwner(address caller, address owner); error AlreadyCompleted(uint256 index); modifier validIndex(uint256 index) { if (index >= todos[msg.sender].length) revert TodoNotFound(index); _; } function addTodo(string calldata content) external { if (bytes(content).length == 0) revert EmptyContent(); Todo memory newTodo = Todo({ content: content, createdAt: block.timestamp, completed: false }); todos[msg.sender].push(newTodo); uint256 index = todos[msg.sender].length - 1; emit TodoAdded(msg.sender, index, content); } function completeTodo(uint256 index) external validIndex(index) { Todo storage todo = todos[msg.sender][index]; if (todo.completed) revert AlreadyCompleted(index); todo.completed = true; emit TodoCompleted(msg.sender, index); } function deleteTodo(uint256 index) external validIndex(index) { Todo[] storage userTodos = todos[msg.sender]; // 用最后一个元素覆盖要删除的元素,然后 pop userTodos[index] = userTodos[userTodos.length - 1]; userTodos.pop(); emit TodoDeleted(msg.sender, index); } function getTodo(uint256 index) external view validIndex(index) returns (Todo memory) { return todos[msg.sender][index]; } function getAllTodos() external view returns (Todo[] memory) { return todos[msg.sender]; } function getTodoCount() external view returns (uint256) { return todos[msg.sender].length; } }

代码不长,但每一处都值得说。下面按"最容易被写错的地方"来拆。

决策一:为什么每个函数都隐含"只有创建者能操作"

需求里说"只有创建者能操作自己的待办"。很多人的第一反应是写一个onlyOwner修饰器,或者存一个owner字段做检查。

但这个设计是多余的。因为todos是以msg.sender为键的映射——你调用addTodo,待办存到你自己名下;你调用completeTodo,改的是你自己数组里的元素。你根本碰不到别人的待办,因为你的索引查的是你自己的数组。

这就是"数据结构即权限"的思路。与其在每个函数里加检查,不如让数据结构天然隔离。这是智能合约设计里非常优雅的一种模式——把权限约束下沉到存储层,而不是散落在业务逻辑里。

不过有个细节要注意:validIndex修饰器检查的是todos[msg.sender].length,也就是说"索引是否在你的数组范围内"。如果你的数组只有 3 个元素,你传index = 5,会直接TodoNotFound。这既是对索引的校验,也间接防止了越界访问。

决策二:删除操作为什么用"覆盖 + pop"

这是整份代码里最需要动脑的地方。

userTodos[index] = userTodos[userTodos.length - 1]; userTodos.pop();

为什么不直接delete userTodos[index]?因为delete只是把那个位置重置为零值,数组长度不变。结果就是数组里留了个"空洞"——一个 content 为空、completed 为 false 的幽灵元素。后续遍历时它会一直存在,索引也会错乱。

为什么不用for循环把后面的元素往前挪?因为那样删除的成本是 O(n),元素越多越贵。在链上,O(n) 的代价可能是几十美元。

"覆盖 + pop" 的代价是 O(1),但代价是数组顺序会变。如果你删除索引 1,最后一个元素会被搬到索引 1 的位置。所以这个方案适合"顺序无所谓"的场景。

待办事项正好符合——用户不关心待办的物理顺序,只关心它们存在。如果你要写一个"顺序敏感"的列表(比如聊天记录、交易流水),就不能用这个方案,得用"标记删除 + 惰性压缩"。

这是一个典型的工程权衡:用 O(1) 的删除换来顺序的丢失。写合约时,你要清楚每个取舍的代价。

决策三:storage 指针的微妙之处

Todo storage todo = todos[msg.sender][index]; todo.completed = true;

如果把storage换成memory,todo.completed = true改的是内存副本,链上状态纹丝不动,函数看起来"成功"了但什么都没发生。

storage让变量成为链上数据的引用,memory让它成为副本。这个区别在修改状态时是生死攸关的。

反过来看addTodo:

Todo memory newTodo = Todo({...}); todos[msg.sender].push(newTodo);

这里用memory是对的——我们先在内存里组装好数据,再push到链上。如果用storage,反而会多一层不必要的引用。

一个经验法则:要"读并修改"用storage,要"组装后写入"用memory。

决策四:事件里的 indexed 到底标什么

event TodoAdded(address indexed owner, uint256 indexed index, string content);

三个参数里,owner和index标了indexed,content没标。

indexed的价值在于"链下过滤"。前端想查"某个地址的所有待办",可以按owner过滤;想查"某个待办是被谁添加的",可以按index过滤。一个事件最多三个indexed参数,这里用了两个,留了一个余量。

为什么content不标indexed?因为string是动态类型,indexed存储的是它的哈希而非原值。如果你需要原值,就不能indexed。而且过滤字符串的意义不大——没人会按"待办内容"来查。

事件是给链下世界看的,合约自己读不到。所以emit的时机和内容,要站在"前端和索引器需要什么"的角度来设计。

决策五:错误类型为什么全用 custom error

error EmptyContent(); error TodoNotFound(uint256 index); error NotOwner(address caller, address owner); error AlreadyCompleted(uint256 index);

Custom error 比require(condition, "string")省 Gas,因为字符串要存进字节码。而且它可以带参数——TodoNotFound(5)比"Todo not found"信息量大得多,前端能精确捕获并展示。

注意我留了一个NotOwner但代码里没用。这是故意的——它展示了"如果不用数据结构隔离权限,你会需要什么"。如果你把待办存在一个全局数组里,每个待办记录一个 owner,那你就必须在completeTodo里写if (msg.sender != todo.owner) revert NotOwner(...)。对比一下,就知道"数据结构即权限"省了多少事。

决策六:completeTodo 里的 AlreadyCompleted 检查

if (todo.completed) revert AlreadyCompleted(index);

为什么要有这个检查?因为如果没有它,用户可以对同一个待办调用一百次completeTodo,每次都 emit 一个事件。状态虽然没变(还是 true),但事件日志会被污染,索引器会收到一堆重复记录。

这是"状态机思维"的体现:待办只有"未完成"和"已完成"两个状态,从"已完成"再"完成"是一个非法转移。显式拒绝非法转移,比默默接受要干净得多。

决策七:getAllTodos 返回 memory 数组的代价

function getAllTodos() external view returns (Todo[] memory) { return todos[msg.sender]; }

这是最方便但也最"贵"的函数。它把整个数组拷贝到内存再返回。如果用户有 1000 个待办,这个调用会消耗大量 Gas——虽然view函数不花真钱(除非在交易里调用),但它有 Gas 上限,太大会直接失败。

生产环境的做法是分页:

function getTodos(uint256 offset, uint256 limit) external view returns (Todo[] memory) { Todo[] storage userTodos = todos[msg.sender]; if (offset >= userTodos.length) return new Todo[](0); uint256 end = offset + limit; if (end > userTodos.length) end = userTodos.length; Todo[] memory result = new Todo[](end - offset); for (uint256 i = offset; i < end; i++) { result[i - offset] = userTodos[i]; } return result; }

分页是"链上数据查询"的标准模式。前端按需拉取,而不是一次性把全部数据拖回来。教学版为了简洁省略了它,但你要知道这个省略意味着什么。

写完之后,用攻击者的眼睛再看一遍

合约能跑不代表安全。假设我是攻击者,我会问:

我能操作别人的待办吗?不能。所有操作都以msg.sender为键,物理隔离。

我能越界访问吗?不能。validIndex检查了索引范围。

我能重复操作吗?completeTodo拒绝已完成的,addTodo每次都是新增,没有幂等问题。

删除会不会留下垃圾数据?不会。覆盖 + pop 保证了数组没有空洞。

有没有重入风险?没有。合约里没有外部调用,没有 ETH 转账,重入的前提都不存在。

Gas 会不会被恶意撑爆?getAllTodos有这个风险,生产环境要改成分页。

如果你想再进一步

这份代码是"教学完整版",但还有三个可以深化的方向。

第一,加一个"编辑"功能。修改待办内容,只允许创建者操作,已完成的不能改。这会逼你想清楚"哪些状态转换是合法的"。

第二,加一个"截止时间"。每个待办带一个 deadline,过期后不能完成。这会引入block.timestamp的使用和"时间比较"的逻辑。

第三,加上分页和事件索引。把getAllTodos改成分页版本,并设计一套让前端能高效查询的事件结构。这是从"教学"到"生产"的跨越。

这个合约最值得带走的东西,不是代码本身,而是那七个决策背后的思考方式:用数据结构隔离权限、用 O(1) 删除换顺序、用 storage 指针改状态、用事件服务链下、用 custom error 省 Gas、用状态机拒绝非法转移、用分页控制成本。

这些模式会在你写的每一个合约里反复出现。下一篇我们进 Ethernaut,用真实的攻击场景来检验这些模式到底牢不牢。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 16:34:59

免环境训练工具实战指南:从YOLO配置到模型部署的完整链路

简介&#xff1a;Yolo系列免环境训练工具提供了一站式的目标检测解决方案&#xff0c;整合YOLOv3/v4/v8的自动标注、模型转换与训练能力&#xff0c;面向需要快速落地项目的算法工程师、学生与研究者&#xff0c;有效解决深度学习环境搭建繁琐的痛点&#xff0c;尤其适合N卡用户…

作者头像 李华
网站建设 2026/10/10 16:32:26

基于机器学习的Web日志异常检测:Python实战与避坑指南

简介&#xff1a;这是一份面向安全运维与日志分析学习者的Python实战项目&#xff0c;聚焦在命令行终端下完成Web日志审计与异常排查。它把访问量统计、日志审查、请求统计与恶意请求识别整合为可运行工具&#xff0c;并借助机器学习模型区分正常与可疑流量&#xff0c;适合具备…

作者头像 李华
网站建设 2026/10/10 16:26:23

虚拟电池模型:把空调集群灵活性变成可调度的储能约束

简介&#xff1a;面向电力系统优化调度研究者与能源工程从业者&#xff0c;此资源聚焦需求侧灵活性刻画&#xff0c;以虚拟电池&#xff08;VB&#xff09;模型统一描述电动汽车与温控负载的功率/电能边界&#xff0c;并给出基于pulp库的日前优化策略完整Python复现。压缩包仅含…

作者头像 李华
网站建设 2026/10/10 16:25:03

YOLOv3旋转角检测ROS包:工业级实时抓取姿态输出

简介&#xff1a;本资源是一个基于YOLOv3与PyTorch实现的ROS机器人抓取检测功能包&#xff0c;面向ROS初学者及机器人视觉方向开发者&#xff0c;解决在Ubuntu 16.04/18.04环境下利用YOLO进行实时物体识别与抓握姿态&#xff08;含旋转角度&#xff09;估计的实际问题&#xff…

作者头像 李华