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 权限已废,没有后门提款路径。

这类东西,最值得学习的是机制,不是情绪。