Crypto OS
Technical Crypto OS第二阶段 · Smart Contract Engineering

T7 · Solidity 基础

一份合约部署到链上之后是什么形态?

练习的能力
Builder
动手
写一个最小的存取款合约,部署到测试网,用浏览器验证源码并调用它的函数。
AI Lab
让 AI 给你的合约写注释和 NatSpec,自己检查它有没有把权限说明写错。

一个现实问题

产品要一个押金托管:用户把钱存进来,随时能取回,出现纠纷时平台能介入处理。

你写过几十个这样的服务,二十分钟就能出一版。但这一次的「上线」和你习惯的上线不是一回事:

  • 代码是公开的,任何人都能反编译,多数项目还会主动把源码贴出来。
  • 数据库是公开的,每个用户存了多少,全世界都能查。
  • 没有 Nginx,没有网关,没有登录页。任何人都能直接调用任何一个对外的函数,用他自己写的脚本,不经过你的前端。
  • 没有灰度,没有回滚,没有 hotfix。改一行代码意味着换一个地址。

同一段业务逻辑,放在这个环境里就变成了另一种东西。写之前得先回答一个问题:一份合约部署之后,究竟变成了什么?

思想实验

把一个部署好的合约想成广场上的一台自动售货机。

它被放在一个固定的位置(地址),机器里的机械结构是焊死的(代码不可改),机器里装的货和钱是它自己的(存储),面板上有一排按钮(对外函数)。

然后加上四个和真实售货机不一样的地方:

  1. 没有营业时间,没有店员。 任何人在任何时刻都能按任何一个按钮。
  2. 玻璃是全透明的。 里面有多少钱、每一格装了什么,人人可见,没有「后台才能看」这回事。
  3. 按钮的顺序和次数由按的人决定。 没有人保证他会按你设计的流程走,他可以只按第三个按钮一万次。
  4. 机器知道是谁在按。 每一次按下都带着一个无法伪造的身份标识。

第 4 条是你唯一的抓手。「只有我能做这件事」在合约里没有别的表达方式,只能是:这个按钮被按下时,检查按的人是不是我

现在把最开始那个押金托管放进这台机器:存款按钮谁都能按(合理),取款按钮谁都能按(但只能取走自己的),而「平台介入」这个按钮——它长什么样,谁能按,按下去会发生什么?

  1. 一个地址 = 一段焊死的代码 + 一块公开的存储
  2. 任何人可以按任何按钮、按任意次
  3. 按下之后没有撤销键
三条加在一起,才是「部署」这两个字在这里的真实含义。

你来决定

「平台介入」这个按钮怎么设计?四种做法,选一种。

观察结果

四个选项摆在一起,浮出来的是同一个结构:

你的设计用户必须信任什么私钥泄露的最坏结果用户能否提前发现
无管理员只信任代码与用户资金无关不需要发现
管理员可提款信任你和你的密钥管理全部资金不能,钱一瞬间就没了
管理员只能暂停信任代码,加上一点可用性风险服务被恶意暂停能,但影响有限
提款需公示加延时信任你,但有观察期全部资金,延后两天能,且有两天时间反应

三件事值得记下来:

第一,权限不是一句承诺,是一段代码。 「我们不会动用户的钱」写在官网上等于零。它要么表现为「代码里根本没有这个函数」,要么表现为「有这个函数,但要过两天」。读者不会读你的官网,他们会读你的合约。

第二,权限的粒度决定了事故的规模。 把「暂停」和「提款」放在同一个 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;
    }
}

读这份代码时,按下面四个角度各读一遍,比逐行读语法有用得多:

  1. 入口清单depositwithdrawsetDepositsPausedtransferOwner。四个入口,就是四个攻击面。每加一个对外函数,都要重新问一遍「任何人都能调它,会怎样」。
  2. 权限表达:只有两个函数带 onlyOwner,而且两个都碰不到 balanceOf。这句话就是上一节那张表里的「只能关阀门」。
  3. 状态变更顺序withdraw 里先把账改完、事件发完,最后才把钱打出去。这个顺序不是风格问题。
  4. 可观测性:每一次状态变化都配了一条事件,并且把关键字段标成了可索引。链下系统能靠这四条事件完整重建每个人的余额——这是你自己设计出来的能力,不是免费的。

再补一条容易被忽略的:mapping 看起来像哈希表,但它不能遍历。你拿不到「所有存过钱的地址」这个列表。想要这个列表,得自己额外维护一个数组,而维护数组又会带来新的成本和新的攻击面。这是合约里最常见的「后端直觉失效」时刻。

它叫什么

State Variable状态变量

声明在合约层面、写进存储的变量。它在交易结束后仍然存在,是合约的全部记忆。

public 修饰的状态变量会自动生成一个同名的读取函数。注意这只是「方便读」,不加 public 也一样能被任何人读到——存储是公开的,只是没有现成的入口而已。

Mapping映射

键到值的存储结构。它没有长度,不能遍历,也无法判断某个键「存在不存在」——没写过的键返回该类型的零值。

它是合约里最常用的记账结构,也是最容易让人误以为「和后端的 Map 一样」的结构。

Function Visibility函数可见性

externalpublic 的函数进入对外入口清单,任何地址都能调用;internalprivate 不生成入口。

这里的 private 是「没有入口」,不是「保密」。 它修饰的数据照样能被任何人从存储里读出来。

Modifier修饰器

可复用的前置检查,写在函数签名上,函数体执行前先跑一遍。_; 是被修饰函数原本的函数体插入的位置。

它的价值是让权限约束出现在函数签名这一行,代码审查时一眼就能扫出「哪些函数是有权限的」。

Event事件

合约在执行时主动写进交易回执的结构化记录,供链下系统读取。

两个硬性质:合约自己读不到它,以及它不发就没有。加了 indexed 的字段可以被高效过滤,一个事件最多三个。设计事件时要想清楚:链下要靠它重建什么。

Revert回滚

中止执行并撤销这笔交易里已经发生的一切状态变化,但 Gas 照扣。

它是合约唯一的「拒绝」方式。自定义 error 比字符串更省,也让调用方能按类型分支处理。

动手

动手写一个最小的存取款合约,部署到测试网并验证源码任意 Solidity 开发框架 + 一个区块浏览器0 元,全程测试网,测试币从水龙头领

目标不是写出多复杂的合约,而是完整走一遍「代码变成一个地址」的过程,并且亲眼看到它对外是什么样子。

把上面那份 Vault 敲一遍。 不要复制粘贴,敲一遍。敲的过程中你会被编译器纠正好几次,那些纠正就是这一章的重点。

在本地跑通四条路径:正常存、正常取、取超过余额(应当被拒绝)、用另一个地址调 setDepositsPaused(应当被拒绝)。

后两条比前两条重要。合约代码的质量上限,由它拒绝的能力决定,T12 会把这句话变成一套方法。

部署到测试网。 先从水龙头领测试币,再部署。记下三样东西:合约地址、部署交易哈希、构造时的区块高度。

全程测试网,不要用任何持有真实资产的私钥。 给这个练习单独生成一个钱包,用完就扔。

在区块浏览器上验证源码。 提交源码、编译器版本和优化设置,浏览器会比对字节码。

验证通过之后刷新页面,你会看到一件重要的事:浏览器自动生成了一个能读能写的界面。你的合约从此有了一个你没写过的前端,任何人都能用。这就是「入口即攻击面」的直观形态。

用浏览器界面调用它。 存一笔、取一笔,然后切到另一个钱包,试着调用 setDepositsPaused,看它怎么失败、报错信息长什么样。

去读存储。 用 RPC 的 eth_getStorageAt 直接读槽 0,你会读到 owner 的地址。

然后做一个实验:给合约加一个 address private secretAdmin;,重新部署,再读一次。你会发现它一样躺在存储里,谁都能读。这个实验做过一次,你这辈子都不会再把 private 当成保密。

把事件抓下来。eth_getLogs 按你的合约地址过滤,把 DepositedWithdrawn 拉出来,只用这些事件算出「每个地址当前余额」,再和 balanceOf 的读取结果对一遍。

对得上,说明你的事件设计是完整的。对不上,说明你漏发了某个状态变化——现在改还来得及,部署到主网之后就晚了。

AI Lab

AI Lab让 AI 给你的合约写 NatSpec 注释,你来抓它写错的权限说明Level 2 · AI Copilot

把你刚写完的 Vault 原样交给模型,分两步问。

第一步:
为这份合约补全 NatSpec 注释。每个函数写清楚:它做什么、谁可以调用、
在什么条件下会回滚、修改了哪些状态、发出哪些事件。不要修改任何代码逻辑。

第二步:
现在假设你是审计员。逐个函数回答:这个函数如果被一个完全陌生的地址
以任意参数、任意次数调用,最坏会发生什么?只回答代码里真实存在的情况,
不要给出改进建议。

第一步的产出看起来会非常专业,这正是危险的地方。模型写注释时是在「补全一份看起来应该长这样的文档」,而不是在读你的代码。 最典型的失败是:它给一个根本没加 onlyOwner 的函数写上「只有合约所有者可以调用」。注释和代码一旦不一致,后面所有读这份代码的人——包括审计员、包括未来的你——都会被这句注释骗一次。

做一个实验:把 setDepositsPausedonlyOwner 删掉,其他一个字不改,再让模型写一遍注释。看它会不会发现。

第二步才是真正有价值的部分。让它站在攻击者视角逐个入口过一遍,它会给出一份「入口清单加最坏情况」的对照表。这份表你自己也该会写——写不出来的函数,说明你还没想清楚它为什么存在。

AI 说完之后,你必须自己验证

  • 它写的每一条权限说明,你都对照函数体确认过:说「只有 owner 能调用」的函数确实带了权限检查
  • 它有没有把「没有权限检查」的函数描述成「受保护的」,这是模型最常犯的一类错
  • 它有没有给某个函数编造出一个代码里并不存在的前置条件或限制
  • 它有没有引用不存在的库、接口或标准,比如一个听起来很合理但查不到的 EIP 编号
  • 它写的 return 说明与函数真实返回值一致,参数说明与参数顺序一致
  • 注释加完之后能编译通过,但编译通过不代表注释是对的,这两件事要分开验

真实案例

把 private 当成保密

不止一个项目把「还没公布的白名单」「抽奖的随机种子」「尚未生效的参数」写进一个 private 变量,以为外界看不到。

存储是公开的,任意一个 RPC 调用就能读出任意槽位。有人靠提前读出种子精准中奖,也有人提前读出白名单做二级市场。

合约里没有秘密。 真要藏,只能藏哈希,原文在揭晓时才上链。

初始化函数谁都能调2017 年,某多签钱包库

一份被大量钱包共用的库合约,有一个用来设置所有者的初始化函数,但没有人限制它只能被调一次、只能被特定的人调。

有人直接调用它,把自己设成了那份库的所有者,然后销毁了它。所有依赖这份库的钱包在同一时刻变成了砖头,里面的钱永久锁死。

教训有两层:对外入口必须假设「任何人都会调它」;以及「这份代码是给别人当库用的」不构成任何保护。为什么销毁一份库会让别的合约瘫痪,T8 的 delegatecall 会解释。

权限转移写反了一个判断

一个项目的所有权转移函数里,条件判断写反,或者把新 owner 填成了零地址并且没有校验。

结果是合约的管理权限永久丢失:暂停不了、升级不了、参数改不了。资金没被偷,但产品死了。

这类事故的共同点是:它们在测试里 100% 不会暴露,因为没有人会写一个「把 owner 转给零地址然后确认合约变砖」的测试用例。两步转移(新 owner 必须主动接受)能挡住绝大多数这类情况。

忘了发事件,链下永远对不上账

一个协议在某次升级里加了一条新的资金路径,但没给它配事件。

链下的记账系统按事件重建余额,于是那条路径上的所有变化都是隐形的。问题在上线三周后才被财务对账发现,期间所有报表都是错的。

事件是合约和外部世界之间唯一的主动通道。 加一条状态变更,就问一句:链下靠什么知道它发生了。

改一个变量

如果把 withdraw 的 external 改成 internal

它从入口清单里消失了。没有任何外部地址能触发它,用户的钱取不出来。

这说明可见性不是修饰细节,而是在定义这份合约对外承诺了哪些能力。审查一份合约时,第一件事就是把所有 external 和 public 函数列出来——那份清单就是它的全部承诺,也是它的全部攻击面。

如果把 mapping 换成一个数组,好让合约能遍历所有存款人

你获得了遍历能力,同时获得了两个新问题。

一是成本:数组随用户增长,任何遍历它的函数最终都会因为超出单笔交易的 Gas 上限而永远无法执行。二是攻击面:攻击者可以用一万个地址各存一分钱,把你的数组撑爆,让某个关键函数彻底卡死。

「需要遍历」在合约里几乎总是一个设计信号:把遍历放到链下去做,链上只保留记账。

如果去掉所有事件

合约功能完全正常,状态变化一个不少。但链下世界瞬间失明:通知发不出,报表算不出,对账做不了,监控也报不了警。

对应到 T1 的那句话——Event 是合约自愿发出的。这一章你站在了「自愿」的那一端,能体会到为什么索引器工程师会对着一个没有事件的合约骂街。

如果合约需要能升级

你会把代码和存储拆开:用户交互的那个地址只负责保管存储和转发调用,真正的逻辑放在另一个可替换的地址上。

于是你得到了修 bug 的能力,同时得到了一个新的最高权限:能换逻辑的人,等于能改写所有规则。升级路径本身就是这一阶段开头说的那条——不可改带来的风险,和可改带来的风险,是两种不同的风险,不是一种被另一种消除。

这条路的技术基础是 T8 的 delegatecall,它的风险清单在 T28。

带走的问题

1
它解决什么问题?

写合约之前先回答:这段逻辑为什么必须放在链上?如果它不需要「任何人都能独立验证」和「发行方也改不了」这两条,放在你的服务器里更便宜、更快、还能改。大部分被写成合约的东西,其实不需要是合约。

9
谁承担风险?

这一章的四个权限方案,本质上是在给同一个问题的不同答案:管理员私钥泄露时,损失由谁承担。答案几乎总是用户。所以权限设计不是内部管理问题,是你替用户做的风险分配,必须公开、必须可验证。

16
Agent 有什么权限?

把「owner 能做什么」这个问题换成「Agent 能做什么」,答案的结构完全一样:不是一个开关,而是一份带范围、带金额、带延时的清单。这一章养成的习惯——把权限写成代码而不是承诺——正是 T32 的地基。

本章自测

一句话带走

合约是一段带持久存储的公开代码,任何人都能调用,所以入口就是攻击面。

做完这一章的动手环节了?勾上它查看全部进度

本页目录