一、一笔我记了三年的账
2023 年 8 月帮一个做链上艺术的小团队 review 元数据方案,合约已经写完、测试网跑通了。我打开一看,每个 token 存了 name、description、image 三个字符串,外加三个 trait 字段,一共 6 个 storage slot,供应量 10,000。
6 × 10,000 = 60,000 次 SSTORE。当时主网 20 gwei、ETH 在 1,700 美元附近,60000 × 20000 gas = 1.2e9 gas,折下来 24 ETH,四万美元出头。而他们的部署预算是 3,000 美元。
我建议 description 和 image 挪到 IPFS,链上只留 CID 的哈希。对方反问:IPFS 挂了怎么办?
这问题问得挺对,因为它把一个技术选型直接变成了钱的问题。我的回答是:你付的从来不是「存储费」,是「共识税」——每一个全节点都要把这块数据永久带在身上,从创世块一直到它硬盘报废。IPFS 挂了你可以重新 pin,链上状态写错了你只能发新合约。
二、gas 表不是常数,它一直在改规则
很多人背 gas 表只背到 SSTORE 20000,但真正影响架构的是 refund 规则。
EIP-3529 在伦敦升级(2021 年 8 月 5 日)里干了两件事:SSTORE 清零的 refund 从 15,000 砍到 4,800,SELFDESTRUCT 的 refund 直接归零,同时把单笔交易的 refund 上限从 gasUsed 的 1/2 压到 1/5。
这套改动很少有人讨论后果。EIP-2929 之后清零一个冷 slot 要花 2,100 + 2,900 = 5,000 gas,返还 4,800,净赚 200 gas,还被 1/5 上限卡着。换句话说 EVM 在设计上就不鼓励你打扫房间,状态只会越堆越多。我在一个空投合约里试过用「清理过期地址」来省成本,最后算下来每清理一条省不到 0.5 美分,而写这段逻辑本身增加了 40 行代码和两个 attack surface。不划算,删了。
真正有效的省法只有两个方向:少写 slot,和把数据挪出执行层。前者靠位打包(一个 uint256 塞 8 个 uint32)和 mapping 替代数组,后者就是下面要说的。
三、链上存 1KB 到底多少钱
32 字节一个 slot,1KB = 32 个 slot = 640,000 gas。按 20 gwei、ETH 3,000 美元算,0.0128 ETH,差不多 38 美元。存一首歌词 38 美元,永久可读,但每次读还要付钱——冷 SLOAD 是 2,100 gas。
对比一下:Dencun 升级(2024 年 3 月 13 日上线主网)带来的 EIP-4844,每个 blob 是 131,072 字节,也就是 128 KiB,目标每区块 3 个、上限 6 个,而且 blob 有独立的 base fee 市场,跟执行层的 base fee 完全解耦。上线后很长一段时间 blob base fee 都趴在 1 wei 的下限附近,偶尔被铭文类项目打上去。
128KB 放 blob 里可能几分钱,放 storage 里是 128 × 32 × 20,000 = 81,920,000 gas。四个数量级的差距。
但代价是 blob 只保证 4,096 个 epoch 可用,约 18.2 天,之后数据就从共识层消失(理论上还能从存档节点捞)。所以它不是「便宜存储」,是「便宜的广播」。把 L2 的交易数据搬上 blob 是对的路子,把用户资产状态搬上去是错的。
顺带说个 Layer2 的坑:在 OP Stack 上做 gas 优化和在主网完全是两个游戏。L2 的成本大头是 calldata 折算成的 L1 数据费,按字节数乘 16 gas 再乘 L1 base fee 和 scalar 系数;执行层那部分反而只值零点零零几 gwei。你花两天把 SSTORE 从 5 次减到 4 次,用户手续费几乎没变;你把一个 uint256 参数拆成两个 uint128 传,反而可能因为字节变多而更贵。
四、升级方案:我越来越倾向于「不升级」
市面上的选择就那几种:
- 不可升级,部署即终局,EIP-170 卡死 24,576 字节上限,超了就得砍功能或者上库合约
- 透明代理(EIP-1967),admin 槽位放在 keccak256("eip1967.proxy.implementation") - 1,靠 msg.sender 判断走不走 delegatecall
- UUPS(EIP-1822),升级逻辑写在实现合约里,省一次 SLOAD,但忘了写 _authorizeUpgrade 的修饰器就等于开了公共后门
- Beacon 代理,多个代理共享一个实现地址,适合工厂批量部署
- Diamond(EIP-2535),多 facet 拼装,能绕过 24,576 字节限制,代价是存储布局全靠团队人肉约定,新人进来基本要重学一遍
透明代理和 UUPS 谁更好的争论,我觉得没太大意义。真正的分歧在于:你的协议应不应该具备「随时改规则」的能力。
如果规则还没定型,说明产品还没定型,这时候更该做的是发一个新合约加一个迁移入口,而不是在主合约里留一个永久的后门。如果规则已经定型,那升级能力就是纯粹的风险敞口。
2017 年 11 月 6 日,devops199 调用了一个多签库的 initWallet,然后 selfdestruct 了库合约,冻结了 513,774 ETH。那不是代理模式的锅,但它说明一件事:可升级性把「代码即法律」悄悄换成了「代码即人」,而人对自己的操作失误是没有免疫力的。
我现在的默认建议:核心资产逻辑不可升级,外围的费率参数、白名单用可升级的小合约隔离,两者之间用权限和时间锁隔开。
五、测试:审计买不到的东西
审计是抽样。一个两千行的合约库,审计师两周能读完接口和主要路径,但读不完状态空间。
所以我从 2023 年起基本都把不变式测试当主战场。Foundry 的写法是在 foundry.toml 里配:
[invariant]
runs = 256
depth = 500
fail_on_revert = false
256 × 500 就是 128,000 次随机调用序列。这个数量级是人工审计做不到的。有个真实的坑:早期版本把 fail_on_revert 设成 true,handler 里随机的 msg.sender 权限不够,几乎每个序列都会 revert,跑出来的绿是假的绿。
框架选型上,Foundry 的 forge 用 Rust 写,编译到测试的循环快,作弊码(vm.prank、vm.warp)顺手;Hardhat 3 这两年开始用 Rust 重写 EDR,还加了原生 Solidity 测试,速度上来了,插件生态仍然更全,做部署脚本和 etherscan 验证还是它省事。我一般是 Foundry 跑不变式和 fuzz,Hardhat 管部署。
还有一个容易被忽略的点:Solidity 版本和 EVM 版本要对齐。PUSH0 是 EIP-3855 带来的,Shanghai 之后才有,0.8.20 开始默认 shanghai,如果你的部署链不支持就会部署失败。transient storage(EIP-1153,TSTORE 100 gas,交易结束自动清空)在 Cancun 之后可用,但 Solidity 到 0.8.28 才把它做成关键字,之前只能写内联汇编。这类细节不看 release notes 根本踩不到。
六、我现在的判断
把智能合约当成出版物,而不是当成微服务。
微服务的思维方式是:出问题了热修、扩容、灰度、回滚。出版物不是,出版物的第一版就是最终版,你只能出勘误页和第二版。链上代码的物理特性——不可逆、状态永久、执行按字节计费——更接近出版而不是运维。
这个思路推下去,很多决策会反过来:与其纠结代理模式,不如把预算花在第一版的正确性上;与其在 L1 上抠 gas,不如把数据层拆到 blob 或链下,只在链上留承诺;与其花钱买一份审计报告发推特,不如写三百行不变式。
上面几个数字如果有算错的地方,欢迎指正,我数学一直不太好。