SATO 的 curve bug,以及 SAT1 如何修复
$SATO 的曲线 bug,以及 $SAT1 如何修复的分析
作者 / X:@ShawnThread
这篇主要讲清楚两件事:
1. SATO 面临的 bug 到底是什么,为什么会出现“不可赎回 gap”。
2. SAT1 为什么说自己是 “SATO bugfix curve”,它到底修在哪里,权限是否还干净。
结论放前面:
SATO 的问题不是 owner 后门,不是项目方偷走 ETH,也不是普通夹子。它的问题是 Uniswap V4 hook 里的曲线会计状态发生漂移。合约里有两套本应永远同步的状态:
ethCum
totalMintedFair
买入按 ethCum 所在的曲线位置定价。卖出按 totalMintedFair 反推出来的曲线位置兑付。理论上它们应该满足:
totalMintedFair == Curve.totalMinted(ethCum)
ethCum == Curve.ethAt(totalMintedFair)
但实际链上它们会漂移。
一旦出现:
ethCum > Curve.ethAt(totalMintedFair)
用户就会变成:
- 买入按更靠后的高曲线买;
- 卖出按更靠前的低曲线卖;
- 中间差额 ETH 仍然在 hook 里,但卖出函数不承认这部分价值。
这就是 SATO 的不可赎回 gap。
1. SATO bug 的实测数据
公开复核过的一个区块:25,041,403
当时大概数据是:
Hook balance ≈ 1774.9398 ETH
feesAccrued ≈ 91.1172 ETH
curve reserve = balance - feesAccrued ≈ 1683.8226 ETH
Curve.ethAt(totalMintedFair) ≈ 1626.8557 ETH
gap ≈ 56.9669 ETH
也就是说,当时已经有约 56.97 ETH 留在 hook 合约里,但无法通过 burn 路径被持币人完整赎回。
注意:这笔 ETH 没有消失,也不是进了某个项目方钱包。它还在 hook 里。
问题是:卖出函数只按 totalMintedFair 那条较低的曲线计算应付 ETH,不承认 ethCum 多出来的那部分价值。
2. 为什么会漂移?
SATO 的设计里,同一条 bonding curve 被拆成两本账:
ethCum // ETH 坐标
totalMintedFair // supply 坐标
在连续数学里,这没有问题。因为正向曲线和反向曲线是互逆的:
q(e) = K * (1 - exp(-e / S))
eth(q) = -S * ln(1 - q / K)
但是 EVM 里不是连续实数。它用的是固定点整数。
SATO 的 Curve.sol 使用 PRBMath UD60x18,exp()、ln()、乘法、除法都会落到整数上。即:
ln(exp(x)) 不保证精确等于 x
exp(ln(y)) 不保证精确等于 y
再加上整数除法会向下取整。每笔误差很小,但如果合约把两个结果分别写进两个 storage,误差就会永久留下来。
3. 买入路径怎么制造误差
SATO 买入时大概是:
uint256 fee = (ethIn * FEE_NUMERATOR) / FEE_DENOMINATOR;
uint256 ethToCurve = ethIn - fee;
uint256 fairSato = Curve.mintFor(ethCum, ethToCurve);
ethCum += ethToCurve;
totalMintedFair += fairSato;
feesAccrued += fee;
这里 fairSato 来自:
Curve.totalMinted(ethCum + ethToCurve)
- Curve.totalMinted(ethCum)
而 totalMinted() 内部依赖 exp()。固定点 exp() 结果有舍入。所以更新后的:
totalMintedFair += fairSato
不一定精确等于:
Curve.totalMinted(ethCum + ethToCurve)
这就是第一层误差。
4. 卖出路径怎么放大漂移
卖出时,SATO 不是从 ethCum 现算当前 fair supply,而是直接使用 storage 里的 totalMintedFair:
uint256 actualSupply = SATO_TOKEN.totalSupply();
uint256 satoFairIn = (satoIn * totalMintedFair) / actualSupply;
uint256 ethRaw = Curve.burnFor(totalMintedFair, satoFairIn);
ethCum -= ethRaw;
totalMintedFair -= satoFairIn;
这里有两层问题。
第一,satoFairIn 是整数除法:
satoIn * totalMintedFair / actualSupply
会向下取整。
第二,burnFor() 使用 ln(),它和买入侧的 exp() 在固定点整数里不是字节级互逆。所以买入和卖出并不是一台完全可逆的机器。
买入用 exp() 把 ETH 坐标推到 supply 坐标。卖出用 ln() 把 supply 坐标推回 ETH 坐标。每次来回都可能少一点、偏一点。由于 SATO 同时保存了 ethCum 和 totalMintedFair,误差没有被重置,而是长期累计。
5. 用一句话解释这个 bug
SATO 同时拿两把尺子量同一条曲线:
- 买入用 A 尺:ethCum
- 卖出用 B 尺:totalMintedFair
数学上 A/B 应该完全重合。但链上 exp、ln、整数除法都有舍入。合约又没有每次把 B 尺重新对齐到 A 尺。于是两把尺子越用越歪。
最后表现为:
用户按 A 尺上的高价买,却按 B 尺上的低价卖。中间差额变成不可赎回的 ETH。
6. 为什么这个 bug 严重?
因为 SATO 的卖点正是 “no operator”。
Token 端没有 owner、pause、blacklist、upgrade。
Hook 端也没有 withdraw、rescue、migrate。
setMinter() 一次性锁死,minter 已经锁到 hook。
如果 gap 已经形成,合约内部没有任何 admin 路径把这部分 ETH 重新分配给 holder。唯一现实方案只能是部署新合约迁移。但 SATO 白皮书新版又强调:
no roadmap
no v2
no fork
no successor token
所以这个 bug 在哲学上也被“不可修复”了。
7. SAT1 是怎么修的?
SAT1 的主页 manifesto 里写得很直白:
SATO bugfix curve
fees compound into ethCum
fair supply derives live
no owner withdraw rescue
它的核心修复不是换一条曲线,而是删掉第二本账。
SATO 是:
uint256 public ethCum;
uint256 public totalMintedFair;
SAT1 变成:
uint256 public ethCum;
function totalMintedFair() external view returns (uint256) {
return Curve.totalMinted(ethCum);
}
也就是说,totalMintedFair 不再是 storage。它每次都从 ethCum 现算。
8. 这为什么能防漂移?
因为 SAT1 只剩一把尺子。
SATO 的结构是:
ethCum storage + totalMintedFair storage
两本账必须长期保持一致。
SAT1 的结构是:
ethCum storage + totalMintedFair() view
只有 ethCum 是主状态。
fair supply 每次用:
Curve.totalMinted(ethCum)
现算。
即使 exp()、ln() 在当次交易里仍然有固定点舍入,误差也不会被写进第二个 storage,变成下一笔交易的历史包袱。
这就是 SAT1 修复的本质:
SATO 的问题是“双账本需要同步但没有同步器”。
SAT1 的解决方案是“只留一本账”。
9. SAT1 的 0.3% 税去哪里了?
SAT1 还有一个关键改动:
SATO 的 fee 单独进 feesAccrued,永远沉淀。
SAT1 没有 feesAccrued。
它把 fee compound 进 ethCum。
买入时:
uint256 fee = (ethIn * 30) / 10000;
uint256 ethToMint = ethIn - fee;
uint256 fairSat1 = Curve.mintFor(ethCum, ethToMint);
ethCum += ethIn; // 注意:加的是全额,包括 fee
卖出时:
uint256 ethRaw = Curve.burnFor(currentFairSupply, sat1FairIn);
uint256 fee = (ethRaw * 30) / 10000;
uint256 ethOut = ethRaw - fee;
ethCum -= ethOut; // 注意:只减实际付出的 ETH,fee 留在 ethCum
所以 SAT1 的 0.3% 不进项目方口袋。也没有 withdraw 函数能提走。它会留在曲线 reserve 里,通过后续 burn 路径按比例分给 holder。可以理解成:
晚买家 / 交易者付费
长期 holder 被动分到 fee
这更像 holder 自动当 LP 的曲线分红。
10. SAT1 权限还干净吗?
我重点看了权限与后门。
owner / Ownable / onlyOwner 不存在
admin / pause / blacklist 不存在
withdraw / rescue / migrate 不存在
upgrade / UUPS / implementation 不存在
delegatecall / selfdestruct 不存在
fallback() 不存在
Token 端只有:
setMinter
transfer
approve
transferFrom
mint
burn
其中 mint() 和 burn() 都只能由 minter 调。
链上 minter() 已经锁为 SAT1 Hook:
0x2a0a30dd78af7698e6f40212b8b8324fce2ee888
我还用真实 DEPLOYER 地址模拟调用 setMinter(0x...dead)。
结果直接 revert:
revert data = 0xf119708d
selector(MinterAlreadySet()) = 0xf119708d
也就是说,即便 DEPLOYER 私钥泄露,也不能再改 minter。
DEPLOYER 的唯一特权已经被消耗。
11. 总结
SATO 的 bug:
双 storage 记同一条曲线
买入用 ethCum
卖出用 totalMintedFair
固定点 exp/ln/整数除法产生误差
误差被长期写入两本账
最终形成不可赎回 gap
SAT1 的修复:
删掉 totalMintedFair storage
只保留 ethCum
fair supply 每次从 ethCum 现算
fees compound into ethCum
直接 ETH donation 不计入 reserve
权限层面无 owner / withdraw / upgrade
从代码角度看,SAT1 确实修掉了 SATO 的核心 bug。
从权限角度看,SAT1 目前也干净,minter 已锁,DEPLOYER 权限已废,没有后门提款路径。
这类东西,最值得学习的是机制,不是情绪。
