用 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用真实的攻击场景来检验这些模式到底牢不牢。

相关新闻

Java数组入门:从定义到遍历全解析

Java数组入门:从定义到遍历全解析

Java学习记录 日期:10.8~9 📖今日知识点 ——什么是数组? 数组:是一种容器,可以用来存储同种数据类型的多个值 (变量能干的,数组能干;变量不能干的,数组能干;…

2026/10/12 0:56:03 阅读更多 →
SringAi 1.0实战:快速使用

SringAi 1.0实战:快速使用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 0:56:08 阅读更多 →
谁才是工作的好搭子:网页版AI给你出难题,本地化AI越用越好用

谁才是工作的好搭子:网页版AI给你出难题,本地化AI越用越好用

我之前也只跟AI聊聊天,觉得网页版就够了。对话式AI有一个非常大的好处,就是方便,打开浏览器就能聊,而且它还能给你生成Word、生成Excel,看起来什么都能做。但一旦接手了一个项目,需要重复用的时候&#xff…

2026/10/12 0:56:13 阅读更多 →

最新新闻

OpenClaw技能合集:从Clawdbot到Moltbot的Agent Skill精选与TaoToken接入实践

OpenClaw技能合集:从Clawdbot到Moltbot的Agent Skill精选与TaoToken接入实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 4:43:48 阅读更多 →
模型上下文协议(MCP)实战:用 TaoToken 统一 Key 打通 Cline MCP 工具链

模型上下文协议(MCP)实战:用 TaoToken 统一 Key 打通 Cline MCP 工具链

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 4:43:48 阅读更多 →
从前端到全栈:用Mastra打造带记忆的客服Agent

从前端到全栈:用Mastra打造带记忆的客服Agent

去年年底,我接到一个任务:给后台管理系统加一个智能客服入口。当时我还没从"纯前端"的舒适区里出来,第一反应是——这不就是接个大模型 API,把用户问题丢过去,再把返回文本显示出来吗?后来真正动…

2026/10/12 4:43:48 阅读更多 →
Playnite 游戏库管理指南:把多平台游戏集中到一个界面

Playnite 游戏库管理指南:把多平台游戏集中到一个界面

Playnite 游戏库管理指南:把多平台游戏集中到一个界面 【免费下载链接】Playnite Video game library manager with support for wide range of 3rd party libraries and game emulation support, providing one unified interface for your games. 项目地址: htt…

2026/10/12 4:43:48 阅读更多 →
CS2卡顿、掉帧、闪退、黑屏?一套系统级排查流程帮你定位根源

CS2卡顿、掉帧、闪退、黑屏?一套系统级排查流程帮你定位根源

先说个现象。CS2 每次大更新之后,社区里讨论得最热烈的往往不是战术配合,而是"为什么我又卡成 PPT 了"。我翻了一下后台的高频问题,卡顿、掉帧、闪退、黑屏卡死这四个词基本能覆盖九成以上的求助帖。但有意思的是,很多人…

2026/10/12 4:43:48 阅读更多 →
SpringBoot+Vue+MySQL学生宿舍管理系统全栈项目实战解析

SpringBoot+Vue+MySQL学生宿舍管理系统全栈项目实战解析

每个做过毕设或课设的人心里都清楚,选对一个项目方向意味着什么。不是越难越好,而是难度刚好卡在答辩能讲清楚、自己也能hold住的区间。这几年Java方向的项目里,“SpringBootVueMySQL”已经成了学生宿舍信息管理系统这类业务系统的标准配置。…

2026/10/12 4:42:48 阅读更多 →

日新闻

复古胶片颗粒感噪点合成器:Canvas ImageData 像素高斯杂色注入算法

复古胶片颗粒感噪点合成器:Canvas ImageData 像素高斯杂色注入算法

在数码相机、高清显示屏与现代矢量图形技术高度发达的今天,画面可以做到绝对的锐利、平滑与无瑕。然而,当一张秋日手账插画或拍立得照片过于“平整无瑕”时,往往会散发出一种冰冷生硬的“数码塑料感(Digital Plasticity&#xff0…

2026/10/12 0:00:59 阅读更多 →
活字印刷古籍线装排版:Canvas 竖排文字与栏线自适应算法

活字印刷古籍线装排版:Canvas 竖排文字与栏线自适应算法

在现代网页与移动端设计中,横排(Horizontal Layout)早已经成为了绝对的主流。然而,当我们翻开泛黄的线装古籍、宋版木刻诗集,或是欣赏一张茶道雅集的手写便签时,那种**自上而下纵向书写、自右向左逐列铺展&…

2026/10/12 0:00:59 阅读更多 →
周日晚间的“精神松绑减震器”:无压力情绪倾倒箱与温和轻声陪伴

周日晚间的“精神松绑减震器”:无压力情绪倾倒箱与温和轻声陪伴

每到周日的晚上八点到十点,很多人心里都会悄悄亮起一盏警示灯。 在心理学上,这种现象有一个专门的称谓——“周日夜晚焦虑症(Sunday Scaries)”。明天又是周一,闹钟又要重新在七点响彻卧房;脑海里仿佛有一个…

2026/10/12 0:00:59 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/12 0:16:30 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/12 0:16:38 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/12 0:16:43 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 10:45:37 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 14:36:53 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 14:36:54 阅读更多 →