资讯详情

资讯详情

用 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换成memorytodo.completed true改的是内存副本链上状态纹丝不动函数看起来成功了但什么都没发生。storage让变量成为链上数据的引用memory让它成为副本。这个区别在修改状态时是生死攸关的。反过来看addTodoTodo 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标了indexedcontent没标。indexed的价值在于链下过滤。前端想查某个地址的所有待办可以按owner过滤想查某个待办是被谁添加的可以按index过滤。一个事件最多三个indexed参数这里用了两个留了一个余量。为什么content不标indexed因为string是动态类型indexed存储的是它的哈希而非原值。如果你需要原值就不能indexed。而且过滤字符串的意义不大——没人会按待办内容来查。事件是给链下世界看的合约自己读不到。所以emit的时机和内容要站在前端和索引器需要什么的角度来设计。决策五错误类型为什么全用 custom errorerror 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用真实的攻击场景来检验这些模式到底牢不牢。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →