Lab1 智能合约入门:14 行代码上链,然后把每一分钱对上账
从装钱包到在区块浏览器上核对手续费,一条完整的部署—调用流水线,外加课件里三处要改的说法。
一句话版
这份 Lab 把一段 14 行的 Solidity 合约从「读懂」一路推到「部署上链、被人调用、在区块浏览器上逐笔对账」,学的是把以太坊当作一台可编程状态机来使用。
题目地图
三十九页,一条流水线:概念 → 拿账户和钱 → 写代码并部署 → 调用并验证。这个顺序不是讲课顺序,是每一次上链开发的真实顺序。
| 页 | 环节 | 关键动作 | 主要失分点 |
|---|---|---|---|
| p.5–8 | 概念 | 合约的四条属性、EOA 与合约账户的分界 | 把 Immutable 理解成「行为也不可变」 |
| p.10–15 | 钱包 | 装 MetaMask、抄助记词、切到测试网 | 把密码和助记词当成一回事 |
| p.16–17 | 领币 | 水龙头发 0.05 测试币 | 以为水龙头在主网上也存在 |
| p.19–25 | 读代码 | 逐行读 Faucet 合约 | 漏看那行空函数体的 receive |
| p.26–30 | 编译部署 | 选编译器版本、接 MetaMask、算手续费 | 编译器版本区间记错 |
| p.32–38 | 调用 | 存钱、取钱、在浏览器上对账 | 只盯 Value 列,看漏内部转账 |
另外提醒一句读图方式:这些截图不是一次连续操作录下来的。p.17 水龙头的收款地址和 p.28 之后部署合约的账户是两个不同地址,余额也对不上。真正能连成一条时间线的是部署之后那一段(p.29 → p.30 → p.35 → p.38),下面的完整例题就验它。
概念卡
1. EOA 与合约账户(Externally Owned Account)
人话定义:有私钥的是人的账户,没私钥的是代码的账户;前者能签名发起交易,后者只能被调用。
例子:课件 p.8 的对照表列了四条差别(有无私钥、谁能让它动、有无代码、能否收发 ETH),但真正的分界只有一条——谁来发起交易。合约没有私钥就没法签名,也就没法主动发交易。这条结论在 p.32 会再用一次:创建合约的那笔交易同样必须来自某个 EOA。
常见误解
看到「合约定时执行」「合约自动调用」就以为合约会自己醒过来 → 背后一定有人或某个 keeper 服务在链外戳它一下。任何一条链上交易的最初触发者都是 EOA(p.8)。
2. receive 与 payable(Receive Function)
人话定义:receive() 是合约接收纯转账的专用入口,payable 是「允许这个函数被调用时附带 ETH」的开关。
例子:Faucet 合约里 receive() external payable {} 这一行函数体是空的,什么都不做,但少了它整个存款流程就不成立。p.35 那笔存款是一笔普通转账,没有 calldata,钱包甚至不知道对面是合约;EVM 执行时发现目标是合约账户且 calldata 为空,就去找它的 receive(),找到了才转账成功。这是全份课件里「一行空代码的存在感」最强的地方。
常见误解
把 payable 理解成「使它成为 fallback」 → payable 只管「能不能附带 ETH 被调用」,没标它的函数收到 ETH 会直接 revert。至于 receive 和 fallback,从 0.6.0 起已经是两个独立函数,课件 p.22 那句是旧版说法的残留(p.22)。
3. value 与 data:一笔交易的两样东西
人话定义:交易能带钱(value),也能带意图(data);两者独立,可以只带一个,也可以都带。
例子:p.36 那张 Deposit / Withdraw 对照表是整份 Lab 概念上最重要的一页。存款的 value 是 0.01 ETH、data 为空;取款的 value 是 0、data 是函数选择器加编码后的参数。data 的结构是「函数签名 keccak 哈希的前 4 字节」+「ABI 编码的参数」——这就是为什么调合约必须先有 ABI:得知道函数长什么样才算得出那 4 个字节。也因此存款任何钱包都做得了,取款必须借助 Remix 或者已经做过源码验证的区块浏览器。
常见误解
以为 ABI 和字节码一起上了链 → 编译产物里只有 bytecode 进链,ABI 是留给调用方的说明书。想让别人在浏览器上直接点按钮调函数,要先做源码验证(Verify & Publish),这份 Lab 跳过了这一步,所以取款只能回 Remix 里做(p.26、p.34)。
4. gas 与 EIP-1559 费用结构(Gas Fee)
人话定义:占用全网算力要付钱,单价分两段——协议按拥堵程度定的 base fee,和你自愿加的小费 priority fee。
例子:p.30 把同一笔部署交易的三个视图并排放在一起,手续费的算法就藏在里面:base fee 0.034811191 GWEI,priority fee 2.5 GWEI,gas used 119,669,乘出来 0.00030333832 ETH,和浏览器显示的 0.000303338320415779 ETH 完全吻合。从这个式子能读出 EIP-1559 之后的分配方式:base fee 被销毁,priority fee 归出块者,这也是「ETH 通缩」说法的来源。
常见误解
把 Gas Limit 当成实际花费 → limit 是你愿意承担的上限,实际按 gas used 结算,多出来的额度退回。另一处是以为交易失败就不花钱:require 失败会回滚全部状态改动,但已经消耗掉的 gas 不退(p.24、p.30)。
逐题拆解
第一步:选网络比什么都重要(p.15、p.28)。测试网和主网共用同一套地址体系和同一把私钥,同一个地址在两条链上各有独立余额。好处是换网不用换账户,风险是操作手感完全一样——Remix 的 Environment 选成 Injected Provider 之后,网络由钱包说了算而不归 Remix 管,钱包停在主网,合约就部署到主网、花真钱。部署前先看一眼顶部的网络名,这是一条真能省钱的检查。
第二步:14 行代码在做什么(p.19–25)。这个合约做两件事:任何人可以往里打钱(receive),任何人可以从里面取钱,单次不超过 0.1 ETH(require(withdraw_amount <= 100000000000000000),数一下是 18 位数,即 10^17 wei)。它没有任何身份检查、没有频率限制、没有总额限制——对水龙头来说这是设计意图,换成别的业务场景,这段代码就是一个谁都能掏空的金库。还有个容易漏的点:合约没有声明任何状态变量,它的余额是 EVM 为每个账户维护的原生字段,由协议层存着。
第三步:部署就是一笔交易(p.29–32)。选好合约点 Deploy,钱包弹窗确认,和转账一样要付 gas、要等出块、也可能失败。特别的地方在于这笔交易的 to 字段是空的;合约地址由发送方地址和 nonce 算出来,取 keccak 哈希的后 20 字节。两个推论跟着出来:地址在交易被打包之前就能确定;同一个账户用同一个 nonce 只能创建一个合约,nonce 递增保证地址不冲突。浏览器把 To 渲染成 [0xca26…9939 Created],就是因为它认出 to 为空意味着创建合约。
第四步:对账时容易看漏的那一列(p.35–38)。取款那笔交易在列表里的 Value 显示 0 ETH,第一次看必然困惑——明明取走了 0.0001 ETH。原因是外层那笔交易是 EOA 调用合约函数,它本身没带 value;真正的 0.0001 ETH 由合约在执行过程中发出,属于内部转账,浏览器要靠重放交易(trace)才能还原出来。证据就在页面本身:p.34 的合约页还没有 Internal Transactions 标签,p.38 出现了,因为直到这一刻合约才第一次主动往外发钱。
课件里的坑
- [课件有误] p.20 把
^0.8.0解释成 “greater than 0.8.0 but less than 1.0.0”,两处都不准:应为大于等于 0.8.0,上界是 0.9.0。原因是主版本号为 0 时 caret 把次版本号当作主版本号对待。这也解释了 p.27 为什么选 0.8.18——它落在区间里;选成 0.9.x 会直接编译失败。这是最可能被拿去出题的一处(p.20、p.27)。 - [术语区别] p.22 把
receive称作 “a fallback function”。0.6.0 之前确实只有一个匿名的function () payable {}兼任两职,之后两者分家:receive接 calldata 为空的纯转账,fallback接匹配不上任何函数签名的调用,各自最多一个(p.22)。 - [课件有误] p.32 写创建合约的交易 “destined to address 0”。准确说法是它的
to字段为空(RLP 编码里是零长度字节串)。0x000…000是一个真实存在、没人持有私钥的地址,往它转账等于销毁,不会创建任何东西(p.32)。 - [课件留白] p.15 下拉里的 Goerli 和 Linea Goerli 已经停止维护,截图摄于 2023 年。本课只用 Sepolia,不受影响,别把那张图当成当前可用测试网清单(p.15)。
- [补充]
withdraw标payable是多余的——它只往外发钱。标了之后别人能在取款时顺带塞钱进来且合约不做任何记录,代码审计会把它标成权限过宽(p.23)。 - [补充]
transfer固定只转发 2300 gas,本意是防重入,但 2019 年 Istanbul 升级改了部分操作码的 gas 定价,带存储写入的接收方合约会因为额度不够而收款失败。现在推荐(bool ok, ) = payable(msg.sender).call{value: amount}(""); require(ok);配合显式重入保护(p.25)。
课后 10 分钟:考点复习
这 10 分钟怎么用:合上页面,先默写三条——一笔交易能带哪两样东西、手续费怎么算、合约地址由什么决定;再把下面的「变式题」算一遍;最后回查两处最容易错的地方——caret 版本区间的两个端点、取款那笔交易的 Value 为什么是 0。三步做完再往下看答案。
必背
- 合约账户没有私钥,不能主动发起交易;任何一条链上交易的最初触发者一定是某个 EOA。
- caret 版本号 ^0.8.0 的含义是大于等于 0.8.0 且小于 0.9.0;主版本号为 0 时 caret 把次版本号当作主版本号对待。
- 自 Solidity 0.6.0 起 receive 与 fallback 是两个不同的函数:前者接 calldata 为空的纯转账,后者接匹配不上任何函数签名的调用。
- 合约若既没有 receive 也没有 payable 的 fallback,别人直接往它地址转账会 revert,gas 白花。
- 一笔交易能携带两样东西:value 是钱,data 是意图;只带 value 就是转账,带了 data 才是函数调用,两者可以同时带。
- 手续费 = (base fee + priority fee) × gas used,其中 base fee 被协议销毁、priority fee 归出块者。
- 创建合约的交易其 to 字段是空的;合约地址由发送方地址和 nonce 算出,交易上链之前就能确定。
- 合约往外转的钱不出现在主交易列表的 Value 列,它属于 internal transaction,要单独看那个标签页。
完整例题
把 p.29 到 p.38 这条余额链完整验一遍,四个数字必须首尾闭合。
已知:部署前账户余额 1.009694916 ETH;部署交易 base fee 0.034811191 GWEI、priority fee 2.5 GWEI、gas used 119,669。
| 步骤 | 计算 | 结果 |
|---|---|---|
| 1. 部署手续费 | (0.034811191 + 2.5) × 10⁻⁹ × 119,669 | 0.00030333832 ETH |
| 2. 部署后账户余额 | 1.009694916 − 0.000303338 | 1.009391578 ETH |
| 3. 存款后合约余额 | 0 + 0.01 | 0.01 ETH |
| 4. 取款后合约余额 | 0.01 − 0.0001 | 0.0099 ETH |
逐步说明:
- 第 1 步把两段费率相加再乘以实际消耗的 gas。浏览器显示的 0.000303338320415779 ETH 与手算结果吻合到小数点后八位,差异只来自截图上的费率被四舍五入了。注意这里用的是 gas used 而不是 Gas Limit;这笔交易两者恰好都是 119,669,但仅凭两者相等不能反推钱包如何估算上限。
- 第 2 步算出 1.009391578,和 p.35 截图里的 1.00939158 对得上。这一步的意义在于确认两张截图之间账户没有别的支出——它们确实属于同一条时间线,可以连着读。
- 第 3 步的存款是一笔 Value = 0.01 ETH 的普通转账,手续费只有 0.00003194 ETH,比部署低一个量级:纯转账 21000 gas 左右,部署要把整段字节码写上链。
- 第 4 步反推取款金额:合约余额从 0.01 变成 0.0099,差值 0.0001 ETH,所以输入框里填的是 10^14 wei。这笔交易的手续费 0.00007184 ETH,介于纯转账和部署之间,因为它要执行一次
require判断和一次转账。
答题时把两个数分开写:账户余额的变化和合约余额的变化是两条独立的账,部署费从账户扣、存款从账户流向合约、取款从合约流回账户。
变式题(先自己做)
同一个账户在同一条测试网上再部署一次这份合约。这次钱包显示 base fee 1.2 GWEI、priority fee 1.5 GWEI,gas used 仍是 119,669。
(1) 这笔部署要花多少 ETH?(2) 新合约的地址和上一份相同吗,为什么?(3) 部署完先存 0.02 ETH,再在 Remix 的输入框里填 100000000000000000,会发生什么?填 1000000000000000000 呢?(4) 第 (3) 问两次都失败时,交易的 Value 是多少,有没有成功转出的款项?
提示
第 (1) 问先把两段费率加起来再换算单位,1 GWEI = 10⁻⁹ ETH。第 (2) 问回想合约地址由哪两个输入决定,其中哪一个在两次部署之间变了。第 (3) 问数一下位数,再对照 require 那行的常数——注意比较符号是 <= 而不是 <。
参考答案与自检(非官方评分标准)
自检要点:① 费率相加后再乘 gas used,别漏掉 priority fee;② 地址不同要说出是 nonce 变了,不是「因为代码重新编译了」;③ 10^17 恰好等于上限,比较符号是 <=,所以通过;④ Value 写 0,并说明 revert 后没有成功的内部转账。
(1) (1.2 + 1.5) = 2.7 GWEI,2.7 × 10⁻⁹ × 119,669 = 0.0003231063 ETH。比原来那笔贵,因为这次的 base fee 高了三十多倍——费率随网络拥堵浮动,字节码和 gas used 都没变。
(2) 不同。合约地址由发送方地址和该地址的 nonce 共同决定,同一个账户每发一笔交易 nonce 就加一,所以第二次部署必然落在另一个地址上。这也保证了同一个账户不会把自己的旧合约覆盖掉。
(3) 填 100000000000000000 是 10^17 wei = 0.1 ETH,恰好等于 require 里的上限,而比较符号是 <=,所以 require 通过;但合约里只存了 0.02 ETH,transfer 因余额不足而失败,整笔交易照样 revert。填 1000000000000000000 是 10^18 wei = 1 ETH,超过上限,在 require 那一行直接被拦下。两次失败位置不同:一次在 require,一次在 transfer。若已上链,已消耗的 gas 仍收费,未使用的额度不收费;若只在钱包预估或本地模拟中失败、未上链,则没有链上交易费。
(4) Value 为 0 ETH。两次执行都 revert,没有成功转出的 0.1 ETH 或 1 ETH;浏览器即使展示失败调用轨迹,也不等于实际到账。已上链的失败交易仍消耗实际使用的 gas。
闪卡自测
1. 合约能不能自己定时执行?
不能。合约没有私钥就没法签名,也就发不出交易。所谓「自动执行」背后一定有 EOA 或 keeper 服务在链外触发(p.8)。
2. ^0.8.0 允许哪些编译器版本?用 0.9.0 编译这份合约会怎样?
允许 [0.8.0, 0.9.0) 区间内的所有版本。用 0.9.0 会直接编译失败,因为它落在区间之外(p.20、p.27)。
3. 把 receive 那一行删掉,存款操作会发生什么?
那笔普通转账的 calldata 为空,EVM 找不到 receive,合约又没有 payable 的 fallback,交易 revert,钱退回但 gas 白花(p.22、p.35)。
4. 钱包密码和助记词分别保护什么?换一台电脑哪个还有用?
密码只解锁本机这个浏览器扩展、加密本地存着的私钥;助记词用于派生种子;无额外口令的本例中,泄露它会危及派生账户。换设备时密码毫无意义,助记词照样能恢复(p.11–13)。
5. 存款和取款在 to、value、data 三个字段上分别有什么不同?
to 都是合约地址;存款 value 是 0.01 ETH、data 为空;取款 value 是 0、data 是函数选择器加编码后的参数。前者任何钱包都能发,后者需要按 ABI 规则构造选择器和参数,不一定要持有完整 ABI 文件(p.36)。
6. 手续费怎么算?两段费率分别去了哪里?
(base fee + priority fee) × gas used。base fee 由协议按拥堵程度自动调整并被销毁,priority fee 归出块者(p.30)。
7. 创建合约的交易,to 字段填什么?合约地址由什么决定?
to 字段是空的。地址由发送方地址和它的 nonce 算出,取 keccak 哈希的后 20 字节,上链之前就能确定(p.32)。
8. 取款那笔交易 Value 显示 0,钱去哪了?
去了 internal transaction。外层交易本身没带 value,真正的转账由合约在执行过程中发出,浏览器要重放交易才能还原。p.34 的合约页还没有这个标签,p.38 才出现(p.38)。
下一步
先把环境原样跑通一遍——装钱包、切测试网、领一次币、把这份 Faucet 部署出来,别等到 Project 开始才第一次走这条流水线。接着补上课件跳过的那一环:做一次源码验证,然后直接在浏览器上调 withdraw,对比和在 Remix 里调有什么不同,「ABI 不上链」这句话做完才会变成手感。行有余力就主动把合约改出一个漏洞再修掉:加 owner 限制取款权限,把 transfer 换成 call{value:}("") 并检查返回值,重新部署对比 gas 消耗。后面讲到 EVM、gas 和账户模型时回头看 p.30 那一页,那里是整份 Lab 唯一一处把协议设计直接暴露成数字的地方。
个人整理的学习笔记,不是官方材料;数字与结论以课件和讲师为准。