T7 · Solidity 基础
一份合约部署到链上之后是什么形态?
- 练习的能力
- Builder
- 动手
- 写一个最小的存取款合约,部署到测试网,用浏览器验证源码并调用它的函数。
- AI Lab
- 让 AI 给你的合约写注释和 NatSpec,自己检查它有没有把权限说明写错。
一个现实问题
产品要一个押金托管:用户把钱存进来,随时能取回,出现纠纷时平台能介入处理。
你写过几十个这样的服务,二十分钟就能出一版。但这一次的「上线」和你习惯的上线不是一回事:
- 代码是公开的,任何人都能反编译,多数项目还会主动把源码贴出来。
- 数据库是公开的,每个用户存了多少,全世界都能查。
- 没有 Nginx,没有网关,没有登录页。任何人都能直接调用任何一个对外的函数,用他自己写的脚本,不经过你的前端。
- 没有灰度,没有回滚,没有 hotfix。改一行代码意味着换一个地址。
同一段业务逻辑,放在这个环境里就变成了另一种东西。写之前得先回答一个问题:一份合约部署之后,究竟变成了什么?
思想实验
把一个部署好的合约想成广场上的一台自动售货机。
它被放在一个固定的位置(地址),机器里的机械结构是焊死的(代码不可改),机器里装的货和钱是它自己的(存储),面板上有一排按钮(对外函数)。
然后加上四个和真实售货机不一样的地方:
- 没有营业时间,没有店员。 任何人在任何时刻都能按任何一个按钮。
- 玻璃是全透明的。 里面有多少钱、每一格装了什么,人人可见,没有「后台才能看」这回事。
- 按钮的顺序和次数由按的人决定。 没有人保证他会按你设计的流程走,他可以只按第三个按钮一万次。
- 机器知道是谁在按。 每一次按下都带着一个无法伪造的身份标识。
第 4 条是你唯一的抓手。「只有我能做这件事」在合约里没有别的表达方式,只能是:这个按钮被按下时,检查按的人是不是我。
现在把最开始那个押金托管放进这台机器:存款按钮谁都能按(合理),取款按钮谁都能按(但只能取走自己的),而「平台介入」这个按钮——它长什么样,谁能按,按下去会发生什么?
- 一个地址 = 一段焊死的代码 + 一块公开的存储
- 任何人可以按任何按钮、按任意次
- 按下之后没有撤销键
你来决定
「平台介入」这个按钮怎么设计?四种做法,选一种。
观察结果
四个选项摆在一起,浮出来的是同一个结构:
| 你的设计 | 用户必须信任什么 | 私钥泄露的最坏结果 | 用户能否提前发现 |
|---|---|---|---|
| 无管理员 | 只信任代码 | 与用户资金无关 | 不需要发现 |
| 管理员可提款 | 信任你和你的密钥管理 | 全部资金 | 不能,钱一瞬间就没了 |
| 管理员只能暂停 | 信任代码,加上一点可用性风险 | 服务被恶意暂停 | 能,但影响有限 |
| 提款需公示加延时 | 信任你,但有观察期 | 全部资金,延后两天 | 能,且有两天时间反应 |
三件事值得记下来:
第一,权限不是一句承诺,是一段代码。 「我们不会动用户的钱」写在官网上等于零。它要么表现为「代码里根本没有这个函数」,要么表现为「有这个函数,但要过两天」。读者不会读你的官网,他们会读你的合约。
第二,权限的粒度决定了事故的规模。 把「暂停」和「提款」放在同一个 owner 底下,就是把一个小权限和一个大权限捆在同一把钥匙上。
第三,链上不会自动告诉别人发生了什么。 上面那个「公示」之所以有用,是因为你主动写下了一条记录。如果你只是改了个变量没有发出记录,那么监控系统看不见,用户也看不见——T1 里说过,可观测性是合约设计的一部分,不是链自带的功能。这一章是兑现那句话的地方。
建立模型
一个部署好的合约,是四样东西长在一个地址上。
0x1234…(一个地址)
├─ 代码 部署时写死,不可改。除非你事先设计了升级路径
├─ 存储 持久、公开、昂贵。这里没有「私有字段」这回事
├─ 入口 对外可见的函数清单 = 你的攻击面清单
└─ 记录 执行时主动发出的事件,只有链下能读到把你在后端的习惯逐条翻译过来,差异就清楚了:
| 后端里你怎么做 | 在合约里变成什么 |
|---|---|
| 私有字段、内部方法 | 没有真正的私密:不给外部入口不代表读不到,存储槽人人可读 |
| 数据库表 | 存储槽。写一次的代价可能是算一次的上百倍(T8 展开) |
| 中间件统一鉴权 | 没有中间件。每个函数自己检查调用者是谁 |
| 打日志 | 事件。必须自己设计发什么字段,而且合约自己读不回来 |
| 灰度、回滚、hotfix | 都没有。只有你事先设计好的开关和升级路径 |
| 参数校验靠前端 | 前端完全可以绕过。校验必须在合约里做一遍 |
把这些落成代码,最小的押金合约长这样:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4; // 自定义 error 从 0.8.4 起可用
contract Vault {
// ── 存储:这四个变量是这份合约的全部记忆 ──
address public owner;
bool public depositsPaused;
mapping(address => uint256) public balanceOf;
uint256 public totalDeposits;
// ── 记录:链下系统唯一能听见的声音 ──
event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, uint256 amount);
event DepositsPausedSet(bool paused);
event OwnerTransferred(address indexed from, address indexed to);
// ── 拒绝的理由:比 require 的字符串省 Gas,而且调用方能结构化地识别 ──
error NotOwner();
error DepositsArePaused();
error ZeroAmount();
error ZeroAddress();
error InsufficientBalance(uint256 requested, uint256 available);
error TransferFailed();
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
constructor() {
owner = msg.sender;
emit OwnerTransferred(address(0), msg.sender);
}
function deposit() external payable {
if (depositsPaused) revert DepositsArePaused();
if (msg.value == 0) revert ZeroAmount();
balanceOf[msg.sender] += msg.value;
totalDeposits += msg.value;
emit Deposited(msg.sender, msg.value);
}
function withdraw(uint256 amount) external {
if (amount == 0) revert ZeroAmount();
uint256 available = balanceOf[msg.sender];
if (amount > available) revert InsufficientBalance(amount, available);
// 先改账,再付钱。顺序反过来会出大事,原因见 T28
balanceOf[msg.sender] = available - amount;
totalDeposits -= amount;
emit Withdrawn(msg.sender, amount);
(bool ok, ) = msg.sender.call{value: amount}("");
if (!ok) revert TransferFailed();
}
// 管理员只能关阀门,碰不到任何人的余额
function setDepositsPaused(bool paused) external onlyOwner {
depositsPaused = paused;
emit DepositsPausedSet(paused);
}
function transferOwner(address newOwner) external onlyOwner {
if (newOwner == address(0)) revert ZeroAddress();
emit OwnerTransferred(owner, newOwner);
owner = newOwner;
}
}读这份代码时,按下面四个角度各读一遍,比逐行读语法有用得多:
- 入口清单:
deposit、withdraw、setDepositsPaused、transferOwner。四个入口,就是四个攻击面。每加一个对外函数,都要重新问一遍「任何人都能调它,会怎样」。 - 权限表达:只有两个函数带
onlyOwner,而且两个都碰不到balanceOf。这句话就是上一节那张表里的「只能关阀门」。 - 状态变更顺序:
withdraw里先把账改完、事件发完,最后才把钱打出去。这个顺序不是风格问题。 - 可观测性:每一次状态变化都配了一条事件,并且把关键字段标成了可索引。链下系统能靠这四条事件完整重建每个人的余额——这是你自己设计出来的能力,不是免费的。
再补一条容易被忽略的:mapping 看起来像哈希表,但它不能遍历。你拿不到「所有存过钱的地址」这个列表。想要这个列表,得自己额外维护一个数组,而维护数组又会带来新的成本和新的攻击面。这是合约里最常见的「后端直觉失效」时刻。
它叫什么
声明在合约层面、写进存储的变量。它在交易结束后仍然存在,是合约的全部记忆。
public 修饰的状态变量会自动生成一个同名的读取函数。注意这只是「方便读」,不加 public 也一样能被任何人读到——存储是公开的,只是没有现成的入口而已。
键到值的存储结构。它没有长度,不能遍历,也无法判断某个键「存在不存在」——没写过的键返回该类型的零值。
它是合约里最常用的记账结构,也是最容易让人误以为「和后端的 Map 一样」的结构。
external 和 public 的函数进入对外入口清单,任何地址都能调用;internal 和 private 不生成入口。
这里的 private 是「没有入口」,不是「保密」。 它修饰的数据照样能被任何人从存储里读出来。
可复用的前置检查,写在函数签名上,函数体执行前先跑一遍。_; 是被修饰函数原本的函数体插入的位置。
它的价值是让权限约束出现在函数签名这一行,代码审查时一眼就能扫出「哪些函数是有权限的」。
合约在执行时主动写进交易回执的结构化记录,供链下系统读取。
两个硬性质:合约自己读不到它,以及它不发就没有。加了 indexed 的字段可以被高效过滤,一个事件最多三个。设计事件时要想清楚:链下要靠它重建什么。
中止执行并撤销这笔交易里已经发生的一切状态变化,但 Gas 照扣。
它是合约唯一的「拒绝」方式。自定义 error 比字符串更省,也让调用方能按类型分支处理。
动手
目标不是写出多复杂的合约,而是完整走一遍「代码变成一个地址」的过程,并且亲眼看到它对外是什么样子。
把上面那份 Vault 敲一遍。 不要复制粘贴,敲一遍。敲的过程中你会被编译器纠正好几次,那些纠正就是这一章的重点。
在本地跑通四条路径:正常存、正常取、取超过余额(应当被拒绝)、用另一个地址调 setDepositsPaused(应当被拒绝)。
后两条比前两条重要。合约代码的质量上限,由它拒绝的能力决定,T12 会把这句话变成一套方法。
部署到测试网。 先从水龙头领测试币,再部署。记下三样东西:合约地址、部署交易哈希、构造时的区块高度。
全程测试网,不要用任何持有真实资产的私钥。 给这个练习单独生成一个钱包,用完就扔。
在区块浏览器上验证源码。 提交源码、编译器版本和优化设置,浏览器会比对字节码。
验证通过之后刷新页面,你会看到一件重要的事:浏览器自动生成了一个能读能写的界面。你的合约从此有了一个你没写过的前端,任何人都能用。这就是「入口即攻击面」的直观形态。
用浏览器界面调用它。 存一笔、取一笔,然后切到另一个钱包,试着调用 setDepositsPaused,看它怎么失败、报错信息长什么样。
去读存储。 用 RPC 的 eth_getStorageAt 直接读槽 0,你会读到 owner 的地址。
然后做一个实验:给合约加一个 address private secretAdmin;,重新部署,再读一次。你会发现它一样躺在存储里,谁都能读。这个实验做过一次,你这辈子都不会再把 private 当成保密。
把事件抓下来。 用 eth_getLogs 按你的合约地址过滤,把 Deposited 和 Withdrawn 拉出来,只用这些事件算出「每个地址当前余额」,再和 balanceOf 的读取结果对一遍。
对得上,说明你的事件设计是完整的。对不上,说明你漏发了某个状态变化——现在改还来得及,部署到主网之后就晚了。
AI Lab
把你刚写完的 Vault 原样交给模型,分两步问。
第一步:
为这份合约补全 NatSpec 注释。每个函数写清楚:它做什么、谁可以调用、
在什么条件下会回滚、修改了哪些状态、发出哪些事件。不要修改任何代码逻辑。
第二步:
现在假设你是审计员。逐个函数回答:这个函数如果被一个完全陌生的地址
以任意参数、任意次数调用,最坏会发生什么?只回答代码里真实存在的情况,
不要给出改进建议。第一步的产出看起来会非常专业,这正是危险的地方。模型写注释时是在「补全一份看起来应该长这样的文档」,而不是在读你的代码。 最典型的失败是:它给一个根本没加 onlyOwner 的函数写上「只有合约所有者可以调用」。注释和代码一旦不一致,后面所有读这份代码的人——包括审计员、包括未来的你——都会被这句注释骗一次。
做一个实验:把 setDepositsPaused 的 onlyOwner 删掉,其他一个字不改,再让模型写一遍注释。看它会不会发现。
第二步才是真正有价值的部分。让它站在攻击者视角逐个入口过一遍,它会给出一份「入口清单加最坏情况」的对照表。这份表你自己也该会写——写不出来的函数,说明你还没想清楚它为什么存在。
AI 说完之后,你必须自己验证
- 它写的每一条权限说明,你都对照函数体确认过:说「只有 owner 能调用」的函数确实带了权限检查
- 它有没有把「没有权限检查」的函数描述成「受保护的」,这是模型最常犯的一类错
- 它有没有给某个函数编造出一个代码里并不存在的前置条件或限制
- 它有没有引用不存在的库、接口或标准,比如一个听起来很合理但查不到的 EIP 编号
- 它写的 return 说明与函数真实返回值一致,参数说明与参数顺序一致
- 注释加完之后能编译通过,但编译通过不代表注释是对的,这两件事要分开验
真实案例
不止一个项目把「还没公布的白名单」「抽奖的随机种子」「尚未生效的参数」写进一个 private 变量,以为外界看不到。
存储是公开的,任意一个 RPC 调用就能读出任意槽位。有人靠提前读出种子精准中奖,也有人提前读出白名单做二级市场。
合约里没有秘密。 真要藏,只能藏哈希,原文在揭晓时才上链。
一份被大量钱包共用的库合约,有一个用来设置所有者的初始化函数,但没有人限制它只能被调一次、只能被特定的人调。
有人直接调用它,把自己设成了那份库的所有者,然后销毁了它。所有依赖这份库的钱包在同一时刻变成了砖头,里面的钱永久锁死。
教训有两层:对外入口必须假设「任何人都会调它」;以及「这份代码是给别人当库用的」不构成任何保护。为什么销毁一份库会让别的合约瘫痪,T8 的 delegatecall 会解释。
一个项目的所有权转移函数里,条件判断写反,或者把新 owner 填成了零地址并且没有校验。
结果是合约的管理权限永久丢失:暂停不了、升级不了、参数改不了。资金没被偷,但产品死了。
这类事故的共同点是:它们在测试里 100% 不会暴露,因为没有人会写一个「把 owner 转给零地址然后确认合约变砖」的测试用例。两步转移(新 owner 必须主动接受)能挡住绝大多数这类情况。
一个协议在某次升级里加了一条新的资金路径,但没给它配事件。
链下的记账系统按事件重建余额,于是那条路径上的所有变化都是隐形的。问题在上线三周后才被财务对账发现,期间所有报表都是错的。
事件是合约和外部世界之间唯一的主动通道。 加一条状态变更,就问一句:链下靠什么知道它发生了。
改一个变量
它从入口清单里消失了。没有任何外部地址能触发它,用户的钱取不出来。
这说明可见性不是修饰细节,而是在定义这份合约对外承诺了哪些能力。审查一份合约时,第一件事就是把所有 external 和 public 函数列出来——那份清单就是它的全部承诺,也是它的全部攻击面。
你获得了遍历能力,同时获得了两个新问题。
一是成本:数组随用户增长,任何遍历它的函数最终都会因为超出单笔交易的 Gas 上限而永远无法执行。二是攻击面:攻击者可以用一万个地址各存一分钱,把你的数组撑爆,让某个关键函数彻底卡死。
「需要遍历」在合约里几乎总是一个设计信号:把遍历放到链下去做,链上只保留记账。
合约功能完全正常,状态变化一个不少。但链下世界瞬间失明:通知发不出,报表算不出,对账做不了,监控也报不了警。
对应到 T1 的那句话——Event 是合约自愿发出的。这一章你站在了「自愿」的那一端,能体会到为什么索引器工程师会对着一个没有事件的合约骂街。
你会把代码和存储拆开:用户交互的那个地址只负责保管存储和转发调用,真正的逻辑放在另一个可替换的地址上。
于是你得到了修 bug 的能力,同时得到了一个新的最高权限:能换逻辑的人,等于能改写所有规则。升级路径本身就是这一阶段开头说的那条——不可改带来的风险,和可改带来的风险,是两种不同的风险,不是一种被另一种消除。
这条路的技术基础是 T8 的 delegatecall,它的风险清单在 T28。
带走的问题
写合约之前先回答:这段逻辑为什么必须放在链上?如果它不需要「任何人都能独立验证」和「发行方也改不了」这两条,放在你的服务器里更便宜、更快、还能改。大部分被写成合约的东西,其实不需要是合约。
这一章的四个权限方案,本质上是在给同一个问题的不同答案:管理员私钥泄露时,损失由谁承担。答案几乎总是用户。所以权限设计不是内部管理问题,是你替用户做的风险分配,必须公开、必须可验证。
把「owner 能做什么」这个问题换成「Agent 能做什么」,答案的结构完全一样:不是一个开关,而是一份带范围、带金额、带延时的清单。这一章养成的习惯——把权限写成代码而不是承诺——正是 T32 的地基。
本章自测
读得到。private 只表示不生成外部读取入口,不表示数据保密。任何人用一次 RPC 调用读取对应的存储槽就能拿到原文。
需要保密的东西只有两种处理方式:链上只存哈希,原文在揭晓时再公开;或者根本不放到链上。
因为把钱打出去这个动作,会把执行权交给接收方——如果接收方是一个合约,它可以在收到钱的那一刻回头再调一次你的 withdraw。
如果这时候余额还没扣,它就能重复取走。先改账再付钱,是让这条路径在第二次进来时自然失效。
完整的攻击原理和更多变体在 T28,这里先把顺序记成肌肉记忆。
执行效果上没有区别,可读性和可审计性上有很大区别。
modifier 让权限约束出现在函数签名那一行。审查代码时,扫一遍签名就能得到「哪些函数有权限保护、是哪种保护」的完整清单;写在函数体里的检查则必须逐个函数读进去才能发现。
一份合约被审计、被别人接手、被半年后的你重读的次数,远多于被写的次数。
判断标准只有一个:链下系统要靠它重建什么。
把你想让链下算出来的东西列出来(每个人的余额、总量的变化、某笔操作的发起人),倒推需要哪些字段。凡是链下需要用来「按条件筛选」的字段,加 indexed——通常是地址和 ID,最多三个。金额一般不需要 indexed,因为没人会按精确金额去筛。
一个实用的验收方法:只用事件,能不能完整重建当前状态。能,就够了。
没有标准答案,检查这几件事:
- 每一条权限,是否都能说出「不给会怎样」。说不出来的,删掉。
- 有没有把「关阀门」和「搬钱」混在同一个角色里。
- 最高权限的那把钥匙现在在哪,是单签还是多签,泄露了会怎样。
- 每一次权限被使用时,链上有没有留下一条任何人都能看到的记录。
- 如果你自己消失了,这份合约还能正常工作吗。
第 5 条最容易被跳过,也最能暴露设计里藏着的中心化假设。
一句话带走
合约是一段带持久存储的公开代码,任何人都能调用,所以入口就是攻击面。