docs(review): apply batch-04 consultant fixes and approve 5 tutorial/web3 posts
Deploy / build (push) Successful in 22s
Deploy / build (push) Successful in 22s
- performUpkeep: external callable, re-check is mandatory security (not stale-data hygiene) - BasicNft.tokenURI: ownerOf -> _ownerOf, dead custom error explained - storage (SSTORE) vs EIP-170 runtime bytecode separated, derived ~1.3M gas for the two SVGs - subs-15 retimed from a fresh faster-whisper word alignment (10.84s first line, was 28.22s) + SRT regenerated - RDAP needs curl -L (bootstrap 302), ASN/Spamhaus blocklist check added - learning-project framing -> Sepolia reference implementation / testnet validation build - dynamicCta on the two posts that ended with no conversion block - cross-links between the two NFT posts; tracker 91/91 approved
This commit is contained in:
1 parent
01c55805fb
commit
cd6511a0b3
14 files changed
+322
-166
No files matched your search
@@ -18,8 +18,9 @@ draft: false
|
||||
预测、也无法重掷的地方;开奖必须没有人类按下按钮——因为一个能决定
|
||||
「何时」开奖的人,「何时」本身就已经是一种攻击。这篇文章会讲合约
|
||||
本身、让开奖挂掉的 bug、我是怎么测试它的,以及重做时我会改什么。
|
||||
这是一个学习项目,不是处理真钱的线上代码——我开门见山说出来,
|
||||
因为这篇文章的可信度就靠它。
|
||||
这是一个部署在 Sepolia 测试网上的**参考实现与验证沙盒**,不是经过审计的
|
||||
主网资金级代码——我把这条边界先划清楚,而一份真钱版本必须补齐的四项加固,
|
||||
我列在文末。
|
||||
|
||||
## 为什么这是预言机少数真正有用的场景之一
|
||||
|
||||
@@ -121,7 +122,7 @@ contract Raffle is VRFConsumerBaseV2Plus, AutomationCompatibleInterface {
|
||||
`Raffle__RaffleNotOpen`。然后把发送者压栈,发出事件:
|
||||
|
||||
```solidity
|
||||
if (msgValue < i_entranceFee) {
|
||||
if (msg.value < i_entranceFee) {
|
||||
revert Raffle__NotEnoughETHEntered();
|
||||
}
|
||||
if (s_raffleState != RaffleState.OPEN) {
|
||||
@@ -141,7 +142,11 @@ bool hasBalance = address(this).balance > 0;
|
||||
upkeepNeeded = (timePassed && isOpen && hasBalance && hasPlayers);
|
||||
```
|
||||
|
||||
**开奖。** `performUpkeep` 只能被网络调用,但它仍然重新检查
|
||||
**开奖。** 正常流程下 `performUpkeep` 由 Chainlink Automation 节点触发,但接口
|
||||
要求它必须是一个无权限修饰符的 `external` 函数 —— 链上任何地址或合约都能直接调用
|
||||
它,而且一笔有效的 keeper 交易也可能落在链下模拟之后状态已变的区块上。所以在
|
||||
`performUpkeep` 内部重新执行 `checkUpkeep` 是必须的安全边界,不是形式主义:正是它
|
||||
挡住了任意外部调用者在条件未满足时强行触发开奖。它仍然重新检查
|
||||
`checkUpkeep`——upkeep 可能拿陈旧数据被触发,纵深防御不花什么成本。
|
||||
检查失败时它回滚一个自定错误,错误里自带证据:
|
||||
|
||||
@@ -276,10 +281,11 @@ yarn browserify pages/1/indexProperCatch.js --standalone bundle -o pages/1/dist/
|
||||
|
||||
## 重做时我会改什么
|
||||
|
||||
这是一个测试网学习项目,我想把边界写清楚:没有审计、没有经济攻击
|
||||
建模、没有主网的钱。README 管它叫「学习项目笔记」,它就是如此。
|
||||
这是一个 Sepolia 参考实现,我想把边界写清楚:没有审计、没有经济攻击
|
||||
建模、没有主网的钱。它存在的目的是端到端验证一套状态机与 keeper 合约,
|
||||
不是收存款。
|
||||
|
||||
运营上,一个真彩票有个学习项目没有的持续成本:VRF 订阅每次请求都会
|
||||
运营上,一个真彩票有个测试网参考实现没有的持续成本:VRF 订阅每次请求都会
|
||||
消耗 LINK,Automation upkeep 也要自己的 LINK 余额。没人会自动充值——
|
||||
我会写一个小监控脚本,给订阅补仓、余额太低时报警。
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@ draft: false
|
||||
集合里,这个字符串指向*别处*:一个 `ipfs://` CID,一个 HTTPS URL。
|
||||
代币是永久的,它指向的东西,却是一个没有任何合约能控制的外部依赖。
|
||||
|
||||
这是一个学习项目,不是生产代码 —— README 里写得很明白 —— 而且这里的
|
||||
一切都跑在 Sepolia 测试网上。我把它写下来,是因为全链上这个模式确实
|
||||
有用,下面的数字都是真实的,代码也小到可以一口气读完。
|
||||
这是一个测试网参考实现,不是生产代码 —— README 里写得很明白 —— 而且这里
|
||||
一切都跑在 Sepolia 上。我把它写下来,是因为全链上这个模式确实有用,
|
||||
下面的数字都是真实的,代码也小到可以一口气读完。
|
||||
|
||||
## 如何铸造一个艺术作品永不消失的 NFT?
|
||||
|
||||
如果你是个刚学 Solidity 的新手,拿这个问题去搜,大部分答案都会把你
|
||||
引向 IPFS,再配一句关于 pinning 的 shrug。对小尺寸的艺术品,有更好的
|
||||
如果你拿这个问题去搜,大部分答案都会把你引向 IPFS,再配一句关于
|
||||
pinning 的 shrug。
|
||||
答案:把艺术*放进合约里*,编码好,然后给钱包一个完整的 `data:` URI,
|
||||
让元数据 JSON 和图片都在同一个字符串里。这样一来,艺术作品就是链上
|
||||
的字节,和代币本身没有区别。
|
||||
@@ -56,7 +56,7 @@ contract BasicNft is ERC721 {
|
||||
function tokenURI(
|
||||
uint256 tokenId
|
||||
) public view override returns (string memory) {
|
||||
if (ownerOf(tokenId) == address(0)) {
|
||||
if (_ownerOf(tokenId) == address(0)) {
|
||||
revert BasicNft__TokenUriNotFound();
|
||||
}
|
||||
return s_tokenIdToUri[tokenId];
|
||||
@@ -65,9 +65,14 @@ contract BasicNft is ERC721 {
|
||||
```
|
||||
|
||||
它刻意做得非常小。`mintNft(tokenUri)` 把你给的字符串按代币存起来,
|
||||
`tokenURI` 原样返回;如果代币从未被铸造,就触发一个自定义错误
|
||||
(比带字符串的 `require` 更省 gas,也自带文档)。我用自己的名字命名
|
||||
集合,因为重点是光明正大地学习:`ERC721("Hoelee", "HOE")`。
|
||||
`tokenURI` 原样返回。这里真正有意思的是那行存在性检查:它调用的是
|
||||
OpenZeppelin 的**内部**函数 `_ownerOf(tokenId) == address(0)`。如果写成
|
||||
`ownerOf(tokenId) == address(0)`(我第一版就是这么写的),编译照样通过,
|
||||
但那是一段死代码:公有函数 `ownerOf` 在 OpenZeppelin 内部就直接 revert 了
|
||||
(v5 抛 `ERC721NonexistentToken`,v4 是 `require`),根本不可能返回
|
||||
`address(0)`,所以你自定义的错误永远不可能被触达。用 `_ownerOf` 才会真正
|
||||
生效;而自定义错误比带字符串的 `require` 更省 gas,也自带文档。集合用我
|
||||
自己的名字命名:`ERC721("Hoelee", "HOE")`。
|
||||
|
||||
这个合约对 URI 指向什么没有任何意见 —— OpenZeppelin 的 `ERC721` 基类
|
||||
也没有 —— 所以艺术住在 URI 所在的地方。
|
||||
@@ -288,15 +293,21 @@ mintNftOnContract(mostRecentDeployed);
|
||||
|
||||
## 如果重来,我会怎么做
|
||||
|
||||
**链上艺术要付部署 gas,而且是永久的。** 两张 SVG 的每一个字节都在
|
||||
部署时一次性付清,然后以构造函数字符串的形式永远住在合约存储里。
|
||||
这是一次性成本,但它是真实的,并且随艺术尺寸增长。
|
||||
**链上艺术付的是存储 gas,部署时一次性付清。** 在 `MoodNft` 里,两个
|
||||
`data:image/svg+xml;base64,` 字符串作为构造函数参数传入,通过 `SSTORE` 写进
|
||||
合约**存储**(Storage)—— 每个从零写满的 32 字节槽位大约 20,000 gas。仓库里
|
||||
这两张编码后的 SVG 分别是 542 和 1,469 字节,也就是 17 + 46 个数据槽(每个
|
||||
字符串再加一个长度槽),所以光艺术本身在部署时就要花掉 **130 万 gas** 量级,
|
||||
合约逻辑一行都还没跑。这是一次性成本,但它随载荷体积线性增长。
|
||||
|
||||
**能放进去的艺术有上限。** EIP-170 把合约代码限制在 24,576 字节以内,
|
||||
艺术字节要和逻辑共用这个预算。笑脸和哭脸这种矢量 SVG 是理想选择 ——
|
||||
小、清晰、可缩放。照片或 3D 模型永远塞不进去,硬塞也只是浪费 gas。
|
||||
我的 `BasicNft` 部署返回 `4102 bytes of code`;
|
||||
`MoodNft` 的字节码还要再叠上两张 SVG。
|
||||
**存储与字节码是两个不同的天花板。** 因为艺术是以构造函数参数的形式进去的,
|
||||
限制它的是区块 gas 上限和 EIP-3860 的 49,152 字节 `initcode` 上限 —— 不是部署
|
||||
后的代码体积。大家常引用的 24,576 字节是 **EIP-170**,管的是运行时字节码:
|
||||
编译后的逻辑,加上你内联成 `constant` 或 `immutable` 的任何东西。如果我为了
|
||||
省掉每次读取的 `SLOAD`,把同样的 SVG 硬编码成 `constant` 字符串,这些字节就
|
||||
会搬进运行时字节码,开始和逻辑抢 EIP-170 的预算。笑脸这种紧凑的矢量艺术两个
|
||||
方向都很宽裕(`BasicNft` 部署返回 `4102 bytes of code`);照片或 3D 模型两个
|
||||
方向都塞不进去,硬塞只是烧 gas。
|
||||
|
||||
**不是每个项目都该这么做。** 如果艺术很大、集合需要可变元数据、或者
|
||||
部署预算紧张,IPFS 或文件存储加一个*可设置的* base URI 才是务实的默认
|
||||
@@ -307,7 +318,8 @@ mintNftOnContract(mostRecentDeployed);
|
||||
**锁死 SVG 的行尾。** 仓库通过 `.gitattributes` 把
|
||||
`img/*.svg` 固定为 LF,保证编码后的字节是确定的;在 Windows 上,
|
||||
`core.autocrlf` 会悄悄注入 CRLF,改变编码后的艺术。这种「艺术就是
|
||||
字节」的模式,对这种隐形 bug 毫不留情。
|
||||
字节」的模式,对这种隐形 bug 毫不留情。这个跨平台字节陷阱的完整排障过程
|
||||
见[《为什么在 Windows 上克隆后,我的链上 NFT 图像变了》](/posts/zh/why-my-on-chain-nft-art-changed-on-windows/)。
|
||||
|
||||
**从第一天起就用 DevOpsTools。** 对单机学习来说,注释里的硬编码地址
|
||||
没关系;一旦有第二次部署,它们就是陷阱。
|
||||
@@ -330,6 +342,11 @@ mintNftOnContract(mostRecentDeployed);
|
||||
`0.000186204671471742 ETH`。也就是说,在测试网上把 NFT 放上去几乎不
|
||||
花钱;IPFS 依赖的代价是隐形的,直到某个 pin 死掉的那一天。
|
||||
|
||||
`MoodNft` 在仓库里没有部署 trace —— 它是在同一个测试网上用同一个
|
||||
`forge script` 部署的,但那份日志我没有留下 —— 所以我能诚实给出的只有
|
||||
上一节那笔存储算术(两张编码后的 SVG 约 130 万 gas),而不是一个实测
|
||||
总量。这个缺口我宁愿写出来,也不愿意印一个指不到具体文件的数字。
|
||||
|
||||
而 `MoodNft` 真正重要的结果根本不需要日志:在 MetaMask 里把部署好的
|
||||
合约和 0 号代币作为 collectible 添加,艺术作品就直接从链上 base64 SVG
|
||||
渲染出来 —— 没有 IPFS 网关、没有网络查找、没有任何需要维持在线的东西。
|
||||
|
||||
@@ -9,6 +9,7 @@ stream: creative-lab
|
||||
tags: ["subtitles", "lrc", "srt", "whisper", "forced-alignment", "ai-music", "python"]
|
||||
ogImage: "/og/zh-generating-subtitles-for-songs-without-timestamps.jpg"
|
||||
banner: "/banners/generating-subtitles-for-songs-without-timestamps.jpg"
|
||||
dynamicCta: true
|
||||
draft: false
|
||||
---
|
||||
我试过的每一个 AI 音乐工具,交出来的东西形状都一样:一个音频文件。没有一个会告诉你**每一句是什么时候唱的**。只想听的话没问题,但只要你想把歌词放到屏幕上 —— 卡拉 OK 的那一行、视频字幕、播放器下面的滚动框 —— 就完全不够用。
|
||||
@@ -84,6 +85,8 @@ for i, ch in enumerate(hanzi_in_word):
|
||||
|
||||
**故障 B —— 四行挤在 0.6 秒里。** 有一首歌的尾奏被识别器整段漏掉,剩下的行被压到相隔 0.18 秒。12 个字不可能在 0.18 秒里唱完。
|
||||
|
||||
**故障 C —— 六行被死死压在硬下限上。** 有一首的整个第一段主歌完全没有可用锚点,于是第 3 条规则把连续六行扫到了最小间隔:每行正好 1.20 秒,大约相当于每秒十个词。输出看起来依然「合理」—— 单调递增、在音频时长范围内 —— 直到我去听那一首,整段主歌六秒就唱完了。只对这一个文件重跑一次对齐,第一行就落到了 10.8 秒而不是 28.2 秒:锚点不是缺失,而是被匹配到了歌里错误的位置。
|
||||
|
||||
所以对齐之后,跑三条规则:
|
||||
|
||||
1. **估计这首歌自己的速率** —— 用相邻两行都已锚定的样本(单位数 ÷ 秒数,取中位数)。每行的期望时长 = `单位数 ÷ 速率`,下限约 1.8 秒。同一批里英文是 0.96 到 2.33 词/秒,中文是 2.3 到 4.1 字/秒,**不能用全局常数**。
|
||||
@@ -141,7 +144,7 @@ a.dispatchEvent(new Event('timeupdate'));
|
||||
|
||||
## 结果
|
||||
|
||||
18 首歌,没有一首自带时间戳。现在每一首都有逐行的 LRC 和 SRT —— 只靠歌词加音频生成。
|
||||
18 首歌,没有一首自带时间戳。现在每一首都有逐行的 LRC 和 SRT —— 只靠歌词加音频生成。上面覆盖率表里的中文那一行,来自另一批单独跑的 11 首中文验证集,不是这一页的播放器。
|
||||
|
||||
| | |
|
||||
|---|---:|
|
||||
@@ -149,8 +152,12 @@ a.dispatchEvent(new Event('timeupdate'));
|
||||
| 字幕行数 | 342 |
|
||||
| 平均锚定覆盖率 | 0.96 |
|
||||
| 全行锚定的歌 | 9 / 18(每行都有时间) |
|
||||
| 处理耗时 | 每首 63–90 秒,纯 CPU |
|
||||
| 占用显卡 | 无 |
|
||||
| 处理耗时 | 每首 63–90 秒,纯 CPU 跑 `faster-whisper` |
|
||||
|
||||
跑在 CPU 上是刻意的,不是我绕不过去的限制:这 18 首全部在 Core i9-13900K 上
|
||||
(跑这套流程的那台 Windows 虚拟机)完成对齐,因为 GPU 要留给生成这些歌曲的
|
||||
生产管线,而 `faster-whisper` int8 每首 63–90 秒已经足够便宜 —— 把对齐任务排在
|
||||
一个扩散生成任务后面等,省下来的那点 CPU 时间根本不值。
|
||||
|
||||
下面每个播放器都会滚动歌词、把当前句保持在中间,旁边还有 **.srt 下载** —— 想烧字进视频或在 VLC 里打开都行。
|
||||
|
||||
@@ -189,7 +196,7 @@ a.dispatchEvent(new Event('timeupdate'));
|
||||
[00:11.56]Underneath the floorboards where the shadows play
|
||||
[00:14.60]I tied a little ribbon at the break of day
|
||||
[00:18.32]A button for a table, a thimble for a cup
|
||||
[00:22.02]I climbs the ivy ladder when the sun comes up
|
||||
[00:22.02]I climb the ivy ladder when the sun comes up
|
||||
[00:27.76]Sugar on the windowsill, a feather in my hand
|
||||
[00:31.42]The garden is a jungle and a wonderland
|
||||
[00:35.22]Careful not to wake the cat sleeping by the door
|
||||
@@ -509,7 +516,7 @@ a.dispatchEvent(new Event('timeupdate'));
|
||||
[00:38.16]Counting every falling tear that drops into the tide
|
||||
[00:44.50]Waiting for the heavy gates of heaven to divide
|
||||
[00:51.16]Do not fear the rising storm or shadows in the deep
|
||||
[00:57.24]There is still a ancient promise that the ocean keeps
|
||||
[00:57.24]There is still an ancient promise that the ocean keeps
|
||||
[01:06.34]Spread your wings and rise above the sea of clouds and rain
|
||||
[01:13.18]Never let the mortal world bind you with a chain
|
||||
[01:19.74]Fly across the mountain peaks where immortal rivers flow
|
||||
@@ -625,23 +632,23 @@ a.dispatchEvent(new Event('timeupdate'));
|
||||
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-15" src="https://content.hoelee.com/file/hoelee/music/Green-Train-to-the-Sea-Mr-Hoelee.mp3"></audio>
|
||||
<pre id="subs-15" hidden>[ti:Green Train to the Sea]
|
||||
[ar:Mr Hoelee]
|
||||
[00:28.22]Clickety-clack upon the track, the green train starts to roll
|
||||
[00:29.42]Leaving all the city gray behind the tunnel hole
|
||||
[00:30.62]Forehead pressed against the glass, I watch the fields go by
|
||||
[00:31.82]Golden wheat and windmills waving underneath the sky
|
||||
[00:33.02]Take me where the spring wind blows across the open blue
|
||||
[00:34.22]Ten miles of wildflowers blooming just for me and you
|
||||
[00:38.36]Every station has a name I've never heard before
|
||||
[00:42.68]Soon the scent of salty air will meet us at the shore
|
||||
[00:57.62]Strangers sharing oranges and stories in the seat
|
||||
[01:01.92]Sunlight dancing in the aisle around my dangling feet
|
||||
[01:06.68]Postcards in my backpack waiting for a stamp and pen
|
||||
[00:10.84]Clickety-clack upon the track, the green train starts to roll
|
||||
[00:14.28]Leaving all the city gray behind the tunnel hole
|
||||
[00:19.42]Forehead pressed against the glass, I watch the fields go by
|
||||
[00:24.20]Golden wheat and windmills waving underneath the sky
|
||||
[00:28.38]Take me where the spring wind blows across the open blue
|
||||
[00:33.78]Ten miles of wildflowers blooming just for me and you
|
||||
[00:38.82]Every station has a name I've never heard before
|
||||
[00:43.46]Soon the scent of salty air will meet us at the shore
|
||||
[00:57.42]Strangers sharing oranges and stories in the seat
|
||||
[01:02.46]Sunlight dancing in the aisle around my dangling feet
|
||||
[01:07.48]Postcards in my backpack waiting for a stamp and pen
|
||||
[01:11.50]To tell the people back at home I'm coming back again
|
||||
[01:16.24]Take me where the spring wind blows across the open blue
|
||||
[01:21.14]Ten miles of wildflowers blooming just for me and you
|
||||
[01:26.34]Every station has a name I've never heard before
|
||||
[01:30.66]Soon the scent of salty air will meet us at the shore
|
||||
[01:46.82]Look, there is the sea...</pre>
|
||||
[01:16.22]Take me where the spring wind blows across the open blue
|
||||
[01:21.52]Ten miles of wildflowers blooming just for me and you
|
||||
[01:26.80]Every station has a name I've never heard before
|
||||
[01:31.40]Soon the scent of salty air will meet us at the shore
|
||||
[01:46.20]Look, there is the sea...</pre>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ blueprintLayer: L4-managed-ops-handover
|
||||
tags: [hosting, vps, due-diligence, whois, devops]
|
||||
ogImage: /og/zh-how-to-verify-a-hosting-provider-before-you-buy.jpg
|
||||
banner: /banners/how-to-verify-a-hosting-provider-before-you-buy.jpg
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
每家主机服务商的首页都是一串承诺:_「自 2005 年起运营」_、_「保证在线率」_、
|
||||
@@ -16,7 +17,9 @@ _「注重隐私」_、_「不限流量」_。其中有些是真的,但出人
|
||||
而且在你刷信用卡、把 DNS 指向他们服务器之前,没人会去核实。
|
||||
|
||||
最近我要为一个有具体要求的项目(隐私司法辖区、付款方式、流量上限)筛选一批
|
||||
服务商。我承受不起只看营销话术,于是搭了一套快速核查流程。它确实揪出了问题——
|
||||
服务商。我承受不起只看营销话术,于是搭了一套快速核查流程 —— 一轮下来,名单上
|
||||
大多数都被筛掉了。我没有留下打分表,所以下面只说它揪出了什么问题,不报具体
|
||||
淘汰率。它确实揪出了问题——
|
||||
有的服务商声称「运营多年」,但域名比我的手机还新;有的服务商自己条款里就静悄悄地
|
||||
禁止了我想跑的东西;还有的上了第三方黑名单。
|
||||
|
||||
@@ -31,9 +34,13 @@ _「注重隐私」_、_「不限流量」_。其中有些是真的,但出人
|
||||
最快的办法是查 WHOIS/RDAP。RDAP 是 WHOIS 的现代替代品,返回 JSON,更好解析:
|
||||
|
||||
```bash
|
||||
curl -s "https://rdap.org/domain/example.tld" | python -m json.tool
|
||||
curl -sL "https://rdap.org/domain/example.tld" | python3 -m json.tool
|
||||
```
|
||||
|
||||
`rdap.org` 只是个引导服务(bootstrap server)—— 它会用 `302` 把请求转到真正持有
|
||||
记录的注册局(`.com` 是 Verisign,依此类推),所以 `-L` 不能省:不加就是空响应,
|
||||
`json.tool` 直接报 `Expecting value: line 1 column 1`。
|
||||
|
||||
在 `events` 数组里找 `registration` 事件——那是**最初**的注册日期,不是最近一次续费。
|
||||
(续费日期和注册商变更会排在时间线的后面,你要找的是*第一次*注册那条。)
|
||||
|
||||
@@ -68,6 +75,12 @@ AUP 告诉你他们*会以什么理由封停你*。这是两份不同的清单
|
||||
卷款跑路和大规模封停事件有长记忆,而且通常直白得很,会直接说哪家是「知名骗子」。
|
||||
- **WHTop / HostAdvice**——评分本身很嘈杂,但「1.8/10 且反复出现同一种投诉」和
|
||||
「1.8 分但只是零散的零星差评」是两码事。
|
||||
- **ASN 归属与 IP 段黑名单(`bgp.he.net`、Spamhaus)**——从服务商的 Looking Glass
|
||||
页面拿一个测试 IP,查它宣告的自治系统号(ASN)。一家自称自建机房、ASN 却指向
|
||||
某家廉价上游的服务商,本质是在转售别人的容量,而不是在运营基础设施;上游的
|
||||
滥用处理方式,也就成了你的滥用处理方式。如果这个 IP 所在的 `/24` 网段常年挂在
|
||||
Spamhaus 名单上,那你的发信与 webhook 信誉从第一天起就已经报废 —— 你自己的配置
|
||||
再干净也没用。
|
||||
|
||||
我找到的最有用的单一信号是:**封停报告。** 一家服务商遇到滥用投诉时的第一反应是
|
||||
「先封停」、第二反应是「收『恢复费』」,这会在某条评论里白纸黑字写出来。那是
|
||||
|
||||
@@ -18,8 +18,8 @@ draft: false
|
||||
部署脚本在**执行部署的那台机器**上恰好从磁盘读到的字节 —— 而在
|
||||
Windows 上,Git 会在脚本看到那些字节之前,悄悄地改写它们。
|
||||
|
||||
这事发生在一个小小的 Foundry 学习项目里,做的是一枚「心情」
|
||||
NFT:一个 ERC-721,作品可以在笑脸 SVG 和哭脸 SVG 之间切换,
|
||||
这事发生在[全链上「心情」NFT](/posts/zh/fully-on-chain-svg-nfts/)这个练习里:
|
||||
一个 ERC-721,作品可以在笑脸 SVG 和哭脸 SVG 之间切换,
|
||||
两者都编码在合约自身中。图像 URI 在部署时由 `img/` 目录里的源
|
||||
文件拼出来,所以这些文件的逐字节内容就是作品本身。下面就是一条
|
||||
坏掉的换行符,如何差点让这幅作品变成「因机器而异」。
|
||||
@@ -51,8 +51,13 @@ printf 'a\nb' | base64 # YQpi
|
||||
printf 'a\r\nb' | base64 # YQ0KYg==
|
||||
```
|
||||
|
||||
一个回车符,就改变了编码后的载荷。这类失败最讨厌的地方在于:
|
||||
没有任何东西会告诉你。SVG 在任何一个编辑器里看起来都一模一样。
|
||||
一个回车符,就改变了编码后的载荷。这里要说精确:真正变掉的**不是画面** ——
|
||||
多出来的 `
|
||||
` 落在 XML 标签之间,浏览器画出来的还是同一个笑脸;变掉的是合约
|
||||
存储的那串字节,而在链看来那就是另一件作品,任何针对这串字符串的 `keccak256`
|
||||
断言(部署安全测试会写的那类校验)都会在一个「看起来一模一样」的载荷上失败。
|
||||
这类失败最讨厌的地方在于:没有任何东西会告诉你。SVG 在任何一个编辑器里看起来
|
||||
都一模一样。
|
||||
`git status` 依然干净,因为 Git 在比较文本时先做了换行归一化。
|
||||
Foundry 也不在意 —— 它不解析 SVG,只是编码字节 —— 所以在任何
|
||||
机器上都没有报错、没有警告。写进合约的作品,就这样静默地取决于
|
||||
@@ -79,7 +84,10 @@ img/*.svg text eol=lf
|
||||
说精确一点:这条规则管的是这些路径的检出,以及文件加入仓库时的
|
||||
归一化。它并没有改写源 SVG —— 那些文件本来就是以 LF 提交的,
|
||||
规则也不碰 blob 内容。它阻止的是规则落地之后、每一次检出时可能
|
||||
发生的分歧。
|
||||
发生的分歧。规则提交之后新做的 clone 会自动是 LF;但如果你是在一个
|
||||
已经把这些文件按 CRLF 检出过的 Windows 工作区里更新,需要手动执行一次
|
||||
`git add --renormalize .`(或重新检出 `img/*.svg`)才能刷新磁盘上的字节
|
||||
—— 光有规则,不会倒回去改你现有的工作区。
|
||||
|
||||
同一个 commit 还修了第二件静默出错的东西:`foundry.toml` 里
|
||||
`remappings` 的一个拼写错误。这条映射决定了
|
||||
@@ -146,8 +154,8 @@ ffi = true # For use of DevOpsTools import from lib/foundry-devops/src/DevOopsTo
|
||||
全部意义所在;而它差一点就被一个文件、一条规则、一次 `printf`
|
||||
就能证明的问题悄悄毁掉。
|
||||
|
||||
坦白说清范围:这是一个学习项目 —— 小而注释详尽的合约与部署
|
||||
脚本,跑在本地 Anvil 节点和 Sepolia 测试网上,不是生产代码。
|
||||
坦白说清范围:这是一个测试网验证工程 —— 小而注释详尽的合约与部署
|
||||
脚本,跑在本地 Anvil 节点和 Sepolia 上,不是生产代码。
|
||||
但「在我机器上是好的」本身就是一条 bug 报告,而这个修法背后
|
||||
的纪律,正是生产部署需要的:精确知道你的工具链在发什么字节。
|
||||
|
||||
|
||||
Reference in new issue
Block a user