Solidity 实战三连:从存储合约到 ERC-20

发布时间:2026/10/10 2:55:12
Solidity 实战三连:从存储合约到 ERC-20 上一篇我们聊了学习路径和课程安排那是地图。这一篇是脚下的路——三个递进式的实战合约每一个都对应一组必须内化的核心概念。我刻意选了这三个能存能取的合约、带白名单的投票合约、最简化的 ERC-20。它们看起来简单但如果你认真写会发现每个都在逼你理解一类问题。写完这三个你对 Solidity 的“手感”就建立起来了。代码我全部在 Remix 里跑通过你可以直接复制过去编译部署。我会在每一段代码后面解释“为什么这么写”而不只是“写了什么”。第一个能存能取的合约——理解状态、可见性和错误处理先看代码这是最简版本// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract SimpleStorage { uint256 private value; address public owner; event ValueChanged(address indexed changer, uint256 oldValue, uint256 newValue); error NotOwner(address caller); error InsufficientBalance(uint256 requested, uint256 available); constructor() { owner msg.sender; } modifier onlyOwner() { if (msg.sender ! owner) revert NotOwner(msg.sender); _; } function set(uint256 newValue) external onlyOwner { uint256 oldValue value; value newValue; emit ValueChanged(msg.sender, oldValue, newValue); } function get() external view returns (uint256) { return value; } }看起来简单但这里藏着五个你必须理解的点。第一private不等于“秘密”。很多人以为private变量别人看不到。错。链上数据全是公开的private只是说“其他合约不能直接读”但任何人用区块浏览器或eth_getStorageAt都能拿到值。不要在链上存密码或私钥这条规则再怎么强调都不为过。第二为什么用error而不是require字符串这是 2026 年的主流写法。require(condition, Not owner)这种写法会把字符串存进字节码部署成本更高回滚时消耗的 Gas 也更多。Custom Error 是 0.8.4 引入的现在已经是标准做法——它更省 Gas而且可以在前端精确捕获错误类型。第三modifier的本质是代码内联。onlyOwner里的_是占位符函数体代码会被插入到那个位置。理解这一点你才能明白 modifier 里的变量作用域是怎么回事。第四event是给链下世界看的。合约自己读不到 event但前端、索引器比如 The Graph靠它来追踪状态变化。indexed关键字让这个参数可以被高效过滤。写合约时凡是状态发生有意义的变化都应该 emit 一个 event。第五external和public的区别。对于只被外部调用的函数external更省 Gas因为参数可以直接从calldata读不用先复制到memory。get()这种只读函数external view是标准搭配。现在你自己动手改一下加一个increment()函数每次调用让 value 加一再加一个reset()函数只有 owner 能重置为零。写完这两个你就理解了状态修改和权限控制的组合。第二个带白名单的投票合约——理解映射、数组和遍历的代价这个合约比第一个复杂一个量级因为它需要管理“多个用户”和“多个提案”。// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract WhitelistVoting { address public admin; uint256 public proposalCount; struct Proposal { string description; uint256 voteCount; uint256 deadline; } mapping(uint256 Proposal) public proposals; mapping(address bool) public whitelist; mapping(uint256 mapping(address bool)) public hasVoted; event WhitelistUpdated(address indexed account, bool status); event ProposalCreated(uint256 indexed proposalId, string description); event Voted(uint256 indexed proposalId, address indexed voter); error NotAdmin(); error NotWhitelisted(address caller); error AlreadyVoted(uint256 proposalId, address voter); error ProposalClosed(uint256 proposalId); error ProposalNotFound(uint256 proposalId); modifier onlyAdmin() { if (msg.sender ! admin) revert NotAdmin(); _; } constructor() { admin msg.sender; } function updateWhitelist(address account, bool status) external onlyAdmin { whitelist[account] status; emit WhitelistUpdated(account, status); } function createProposal(string calldata description, uint256 durationSeconds) external onlyAdmin { uint256 id proposalCount; proposals[id] Proposal({ description: description, voteCount: 0, deadline: block.timestamp durationSeconds }); proposalCount; emit ProposalCreated(id, description); } function vote(uint256 proposalId) external { if (!whitelist[msg.sender]) revert NotWhitelisted(msg.sender); if (proposalId proposalCount) revert ProposalNotFound(proposalId); Proposal storage p proposals[proposalId]; if (block.timestamp p.deadline) revert ProposalClosed(proposalId); if (hasVoted[proposalId][msg.sender]) revert AlreadyVoted(proposalId, msg.sender); hasVoted[proposalId][msg.sender] true; p.voteCount 1; emit Voted(proposalId, msg.sender); } function getProposal(uint256 proposalId) external view returns (Proposal memory) { if (proposalId proposalCount) revert ProposalNotFound(proposalId); return proposals[proposalId]; } }这个合约值得讲的地方比第一个多得多。嵌套映射mapping(uint256 mapping(address bool))是投票类合约的标配。它解决的是“某个提案下某个地址是否已投票”这种二维查询。注意这种结构无法遍历——你没法枚举所有投过票的地址。如果需要枚举得额外维护一个数组。为什么用storage指针看这一行Proposal storage p proposals[proposalId];如果写成Proposal memory p proposals[proposalId]那么p.voteCount 1改的是内存里的副本链上数据纹丝不动。storage关键字让p成为指向链上存储的引用修改p就是修改链上状态。这是 Solidity 新手最容易踩的坑之一而且编译器不一定给你警告。为什么用proposalCount而不是数组因为用mapping加计数器创建提案的成本是固定的 O(1)。如果用数组虽然也能做但获取长度的方式和遍历逻辑会更绕。mappingcount是更常见的模式。block.timestamp的信任边界。矿工/验证者可以在一定范围内微调时间戳大约十几秒。对于投票截止时间这种精度要求不高的场景没问题但如果你要写“精确到秒的拍卖”就得小心了。现在做两个练习第一加一个delegateVote函数让白名单用户可以把票委托给另一个人第二加一个execute函数提案截止后只有 admin 能调用根据voteCount是否超过某个阈值来 emit 一个通过或否决的事件。第三个最简化的 ERC-20——理解标准和“不要自己造轮子”ERC-20 是智能合约世界里被实现最多的接口。我给你一个教学版它实现了核心逻辑但不包含所有生产级细节。生产环境请用 OpenZeppelin 的ERC20.sol。// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract MiniERC20 { string public name MiniToken; string public symbol MINI; uint8 public constant decimals 18; uint256 public totalSupply; mapping(address uint256) public balanceOf; mapping(address mapping(address uint256)) public allowance; event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value); error InsufficientBalance(address from, uint256 requested, uint256 available); error InsufficientAllowance(address owner, address spender, uint256 requested, uint256 available); error ZeroAddress(); constructor(uint256 initialSupply) { if (initialSupply 0) revert ZeroAddress(); _mint(msg.sender, initialSupply); } function transfer(address to, uint256 amount) external returns (bool) { _transfer(msg.sender, to, amount); return true; } function approve(address spender, uint256 amount) external returns (bool) { allowance[msg.sender][spender] amount; emit Approval(msg.sender, spender, amount); return true; } function transferFrom(address from, address to, uint256 amount) external returns (bool) { uint256 allowed allowance[from][msg.sender]; if (allowed amount) { revert InsufficientAllowance(from, msg.sender, amount, allowed); } if (allowed ! type(uint256).max) { allowance[from][msg.sender] allowed - amount; } _transfer(from, to, amount); return true; } function _transfer(address from, address to, uint256 amount) internal { if (to address(0)) revert ZeroAddress(); uint256 fromBalance balanceOf[from]; if (fromBalance amount) { revert InsufficientBalance(from, amount, fromBalance); } unchecked { balanceOf[from] fromBalance - amount; balanceOf[to] amount; } emit Transfer(from, to, amount); } function _mint(address to, uint256 amount) internal { if (to address(0)) revert ZeroAddress(); totalSupply amount; unchecked { balanceOf[to] amount; } emit Transfer(address(0), to, amount); } }这个合约有几个必须讲清楚的设计决策。approve和transferFrom的“授权”模式。为什么需要这两个函数而不是直接transfer因为 DeFi 协议需要“代表用户操作资金”。比如 Uniswap 要拿走你的代币来换另一种它不能直接调用你的transfer只能通过你预先授权的额度来操作。这个模式是 ERC-20 的核心也是很多安全事故的源头。type(uint256).max的特殊处理。很多协议用“无限授权”来节省 Gas。代码里if (allowed ! type(uint256).max)这一行意思是“如果是无限授权就不扣减额度”否则每次transferFrom都要写一次 storage贵得肉疼。unchecked块的用途。0.8.0 之后算术运算默认带溢出检查。但balanceOf[from] - amount这一行前面已经用if (fromBalance amount)保证了不会下溢所以再用unchecked省掉一次检查是安全的。这是 Gas 优化的常见模式先手动检查再用 unchecked 跳过冗余检查。为什么 constructor 里用_mint而不是直接设置balanceOf因为_mint会 emitTransfer事件从address(0)转到目标地址。这是 ERC-20 标准的约定所有代币的“诞生”都应该表现为从零地址转出。索引器靠这个来追踪总供应量变化。现在做两个练习第一加一个burn函数让用户销毁自己的代币第二加一个mint函数只有部署者能调用但加上一个总量上限。做完这两个你就理解了代币的完整生命周期。三个合约写完之后你应该带走什么这三个合约加起来不到 200 行但它们覆盖了 Solidity 实战中 80% 的基础模式。SimpleStorage教你的是状态变量、可见性、modifier、event、custom error。这些是每个合约都会用到的东西。WhitelistVoting教你的是struct、嵌套 mapping、storage 指针、时间戳。这些是“管理多个实体”类合约的标配。MiniERC20教你的是接口标准、授权模式、unchecked 优化、mint/burn 生命周期。这些是“代币类”合约的根基。但最重要的是你应该在这三个合约里反复问自己一个问题“如果我是攻击者我会从哪里下手”SimpleStorage 的set如果忘了onlyOwner任何人都能改值。WhitelistVoting 的vote如果忘了检查hasVoted一个人可以投一万次。MiniERC20 的transferFrom如果忘了扣减 allowance攻击者可以无限提取。这些“如果”就是审计思维的起点。下一篇我们会聊 Ethernaut 的前几关那些关卡就是把这些“如果”变成了真实的攻击场景。写完这三个合约你已经有了“看懂攻击”的基础。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询