上一篇我留了一道自测题:写一个管理"待办事项列表"的合约,支持添加、完成、删除、查询,每个待办有创建时间戳和完成状态,只有创建者能操作自己的待办。
这一篇就是这道题的完整解答。但我不想只给你一份能跑的代码——我想把写这份代码时的每一个决策、每一个"为什么不用那种写法"都摊开来讲。因为真实的合约开发就是这样:写代码只占三成时间,另外七成在想"这样写安不安全、贵不贵、别人能不能钻空子"。
先把需求翻译成数据结构
需求里有几个关键词:待办事项、创建者、创建时间戳、完成状态、增删改查。
第一步不是写函数,是想数据怎么存。这里有个关键判断:一个用户会有多个待办,所以需要"从地址到待办列表"的映射。
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,用真实的攻击场景来检验这些模式到底牢不牢。