Bunni DEX 架构设计与代码实现深度技术分析

Bunni 这个名字底下是两套没有共用代码的合约。跟着 ZeframLou/bunni 走一遍 v1:一个 BunniKey 怎么变成一个 ERC-20、首笔存入的下限为什么挡不住抢跑、withdraw 为什么没有重入锁;再看换到 Uniswap v4 hooks 的 v2,一个取整方向怎么让它损失 840 万美元。

预计
17 分钟

Bunni 有两个版本,它们没有共用一行代码。

v1 是 Uniswap v3 头寸的 ERC-20 包装,仓库在 ZeframLou/bunni,最后一次提交停在 2023 年 5 月。v2 是另一套东西:建在 Uniswap v4 的 hook 上,自带流动性密度函数和再抵押,仓库在 Bunniapp/bunni-v2。2025 年 9 月 2 日 v2 被攻击,损失约 840 万美元,同年 10 月团队宣布关停。

把两者混着讲是这个题目最容易出的错 —— 两个仓库里都有一个叫 BunniHub 的合约,名字一样,职责不一样。下面先把 v1 的源码从头走一遍,引用都出自 commit fd65011c(这个仓库没有打过 tag),再说 v2 换掉了什么。

一个 Bunni 代币等于「哪个池子 + 哪个价格区间」

v3 的 LP 头寸是 ERC-721。每张 NFT 记着自己的价格区间,两张即使区间完全一样也是两个不同的 tokenId,没法互相替换,也就没法拿去做抵押品或者塞进为 ERC-20 写的质押合约。Bunni v1 要解决的就是这一条。

bunni · src/base/Structs.sol + src/BunniHub.sol(节选)
struct BunniKey {
IUniswapV3Pool pool;
int24 tickLower;
int24 tickUpper;
}
contract BunniHub is
IBunniHub,
Owned,
Multicall,
SelfPermit,
LiquidityManagement
{
uint256 internal constant WAD = 1e18;
uint256 internal constant MAX_PROTOCOL_FEE = 5e17;
uint256 internal constant MIN_INITIAL_SHARES = 1e9;
uint256 public override protocolFee;
modifier checkDeadline(uint256 deadline) {
require(block.timestamp <= deadline, "OLD");
_;
}
}

BunniKey 就是「相同」的定义:池子地址、下界 tick、上界 tick。三个字段一定,对应的 ERC-20 就定了。区间相同的 LP 不再各自持有一张 NFT,而是共同持有 Hub 名下同一个头寸的份额。

README 里写清楚了这个需求是从哪来的:项目方愿意在 Uniswap v2 或 Sushiswap 上做流动性激励,因为那边的合约简单、能直接 fork 一堆验证过的质押合约;而单个 LP 更愿意去 v3,同样的钱能赚到多得多的手续费。结果同一个代币的流动性被劈成两半。README 举的例子是 Illuvium:Sushiswap 上 3.9 亿美元 TVL、约 600 万美元日成交、手续费年化 1.36%,而 v3 上只有 50 万美元 TVL、约 20 万美元日成交,年化超过 800% —— 数据标注的日期是 2021 年 12 月 29 日。

Hub 里唯一的存储变量是 protocolFee,所有复杂逻辑集中在这一个合约里,每个池子那一侧只部署一个极薄的 ERC-20。README 把这套叫 hub-spoke,给的理由是两条:新代币的部署便宜得多,以及用户的授权集中到 Hub 一个地址上,省的是反复 approve 的 gas。

graph TD
subgraph "Hub-Spoke Architecture"
Hub[BunniHub核心逻辑]
Hub --> Token1[BunniToken A]
Hub --> Token2[BunniToken B]
Hub --> Token3[BunniToken C]
User1[用户 1] -.-> Hub
User2[用户 2] -.-> Hub
User3[用户 3] -.-> Hub
end
style Hub fill:#f9f,stroke:#333,stroke-width:4px

三个常量里 MIN_INITIAL_SHARES 下面单独讲。MAX_PROTOCOL_FEE5e17,配合 WAD = 1e18 读就是 50%,setProtocolFee 里用 require(value <= MAX_PROTOCOL_FEE, "MAX") 卡着上限。checkDeadline 和 Uniswap periphery 里的同名修饰符是一回事,revert 字符串 "OLD"

代币地址是算出来的,用的是 CREATE3 不是代理

bunni · src/BunniHub.sol · deployBunniToken / getBunniToken
function deployBunniToken(BunniKey calldata key)
public
override
returns (IBunniToken token)
{
bytes32 bunniKeyHash = keccak256(abi.encode(key));
token = IBunniToken(
CREATE3.deploy(
bunniKeyHash,
abi.encodePacked(
type(BunniToken).creationCode,
abi.encode(this, key)
),
0
)
);
// 省略:emit NewBunni
}
function getBunniToken(BunniKey calldata key)
public
view
override
returns (IBunniToken token)
{
token = IBunniToken(CREATE3.getDeployed(keccak256(abi.encode(key))));
uint256 tokenCodeLength;
assembly {
tokenCodeLength := extcodesize(token)
}
if (tokenCodeLength == 0) {
return IBunniToken(address(0));
}
}

salt 是 keccak256(abi.encode(key)),完全由那三个字段决定。所以地址在部署之前就能算出来,Hub 里也就不需要一张「key → token」的映射表:getBunniToken 直接用 CREATE3.getDeployed 把地址算出来,再用 extcodesize 探一下那个地址上有没有代码,没有就返回零地址。省掉的是每次部署一个 SSTORE、每次查询一个 SLOAD。

用的是 solmate 的 CREATE3,不是裸 CREATE2,也不是最小代理。区别值得说一句。Uniswap v2 学习里的 pairFor 是 CREATE2 的典型用法,那段代码里硬编码着一个 init code hash —— 合约字节码改一个字节,算出来的地址就变了,分叉项目照抄源码重新编译之后必须跟着换这一行。CREATE3 绕开了这件事:先用 CREATE2 部署一个固定的小 proxy,再由 proxy 用 CREATE 部署本体,本体的地址只取决于 proxy 的地址和 nonce,和 BunniToken 的字节码无关。代价是多一层部署,换来的是地址只由 key 决定。

deployBunniTokenpublic 且没有任何权限修饰,任何人都可以为任意 key 部署代币。每个 BunniToken 都是一份完整的字节码,不是代理。

bunni · src/BunniToken.sol(节选,全文 52 行)
contract BunniToken is IBunniToken, ERC20 {
IUniswapV3Pool public immutable override pool;
int24 public immutable override tickLower;
int24 public immutable override tickUpper;
IBunniHub public immutable override hub;
// 省略:constructor 用 key 拼出 "Bunni <sym0>/<sym1> LP" 作为名字,
// symbol 固定 "BUNNI-LP",decimals 18
function mint(address to, uint256 amount) external override {
require(msg.sender == address(hub), "WHO");
_mint(to, amount);
}
function burn(address from, uint256 amount) external override {
require(msg.sender == address(hub), "WHO");
_burn(from, amount);
}
}

辐条这一侧一共 52 行,去掉 import 和构造函数就是上面这些。四个字段全是 immutable,值写在部署时的构造参数里,不占存储槽。访问控制只有一句 require(msg.sender == address(hub), "WHO"),没有 Ownable,没有角色表 —— 能铸能烧的只有 Hub 一个地址,而这个地址在部署时就冻住了。基类 ERC20 是仓库内自带的一份 solmate fork,带 EIP-2612 permit。

存款分两半,算流动性的代码不在 deposit 里

bunni · BunniHub.sol · deposit + LiquidityManagement.sol · _addLiquidity(节选)
// src/BunniHub.sol
function deposit(DepositParams calldata params)
external
payable
virtual
override
checkDeadline(params.deadline)
6 collapsed lines
returns (
uint256 shares,
uint128 addedLiquidity,
uint256 amount0,
uint256 amount1
)
{
(uint128 existingLiquidity, , , , ) = params.key.pool.positions(
keccak256(
6 collapsed lines
abi.encodePacked(
address(this),
params.key.tickLower,
params.key.tickUpper
)
)
);
(addedLiquidity, amount0, amount1) = _addLiquidity(
LiquidityManagement.AddLiquidityParams({
key: params.key,
recipient: address(this),
payer: msg.sender,
5 collapsed lines
amount0Desired: params.amount0Desired,
amount1Desired: params.amount1Desired,
amount0Min: params.amount0Min,
amount1Min: params.amount1Min
})
);
shares = _mintShares(
params.key,
params.recipient,
addedLiquidity,
existingLiquidity
);
// 省略:emit Deposit
}
// src/uniswap/LiquidityManagement.sol —— 真正算流动性的地方
function _addLiquidity(AddLiquidityParams memory params)
internal
returns (
uint128 liquidity,
uint256 amount0,
uint256 amount1
)
{
// 省略:两侧都是 0 就直接返回
// compute the liquidity amount
{
(uint160 sqrtPriceX96, , , , , , ) = params.key.pool.slot0();
uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(
params.key.tickLower
);
uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(
params.key.tickUpper
);
liquidity = LiquidityAmounts.getLiquidityForAmounts(
sqrtPriceX96,
sqrtRatioAX96,
sqrtRatioBX96,
params.amount0Desired,
params.amount1Desired
);
}
(amount0, amount1) = params.key.pool.mint(
params.recipient,
params.key.tickLower,
params.key.tickUpper,
liquidity,
// 省略:abi.encode 一个 MintCallbackData,装着 token0 / token1 / fee / payer
);
// 省略:SLIP 滑点检查
}

deposit 自己不算数。它做三件事:读一次 pool.positions(...) 拿到加钱之前的 existingLiquidity,把活交给 _addLiquidity,最后拿 addedLiquidityexistingLiquidity 去换份额。slot0()TickMath.getSqrtRatioAtTickLiquidityAmounts.getLiquidityForAmounts 一个都不在这个函数里,它们在基类 LiquidityManagement._addLiquidity 中。BunniHub.sol 里直接出现这三样的地方是 compound

第 27 行 recipient: address(this) 是这一节的重点:头寸挂在 Hub 自己名下。Bunni v1 全程不碰 NonfungiblePositionManager,它直接对 IUniswapV3Poolmintburncollectpositionsslot0。整个仓库 grep 不到 nonfungible 这个词根。所以「把 v3 的 NFT 包装成 ERC-20」这个说法从一开始就不成立 —— 链上从来没有出现过那张 NFT,Hub 持有的是池子里一个以它自己为 owner 的裸头寸,头寸的 key 是 keccak256(owner, tickLower, tickUpper),也就是上面那段 abi.encodePacked 在拼的东西。

payer: msg.sender 决定钱从哪来。pool.mint 不会自己去拉钱,它回调 uniswapV3MintCallback,Bunni 在回调里按 payer 决定走 safeTransfer 还是 safeTransferFrom。回调的来路校验用的是 factory.getPool(token0, token1, fee) 的返回值比对,而不是 periphery 那套 CREATE2 地址推导,这样在非规范部署的链上也能用。

deposit 标了 payable,但函数体一次都没读 msg.value。那是为了能被 Multicall 包着调用 —— MulticallSelfPermit 都直接继承自 Uniswap v3-periphery,不是 Bunni 自己写的。

顺带说一句迁移路径:仓库里的 BunniMigrator 迁的是 Uniswap v2 的 LP token,合约自述就是「Migrates Uniswap v2 LP tokens to Bunni LP tokens」,它 transferFrom 走 v2 的 pair、burn 掉、再把两种代币 approve 给 Hub 调 deposit。它和 v3 的 NFT 没有关系。

首笔存入按 1:1 起步,而这个下限挡不住抢跑

bunni · src/BunniHub.sol · _mintShares
function _mintShares(
BunniKey calldata key,
address recipient,
uint128 addedLiquidity,
uint128 existingLiquidity
) internal virtual returns (uint256 shares) {
IBunniToken shareToken = getBunniToken(key);
require(address(shareToken) != address(0), "WHAT");
uint256 existingShareSupply = shareToken.totalSupply();
if (existingShareSupply == 0) {
// no existing shares, bootstrap at rate 1:1
shares = addedLiquidity;
// prevent first staker from stealing funds of subsequent stakers
require(shares > MIN_INITIAL_SHARES, "SMOL");
} else {
shares = FullMath.mulDiv(
existingShareSupply,
addedLiquidity,
existingLiquidity
);
require(shares != 0, "0");
}
shareToken.mint(recipient, shares);
}

池子空的时候 shares = addedLiquidity,一比一。紧接着一句 require(shares > MIN_INITIAL_SHARES, "SMOL"),严格大于 1e9。注意它做的是拦截,不是扣除:份额没有被减掉 MIN_INITIAL_SHARES,也没有任何一份被铸给零地址。这和 Uniswap v2 学习mint 的做法不一样,v2 是真的把 1000 份铸给零地址永久锁死,Bunni v1 只是要求第一笔够大。

源码注释里那条被我省掉的链接指向 code4rena 2022 年 1 月 Sherlock 审计报告的 H-01「first user can steal everyone else’s tokens」,作者显然知道这类攻击。但仓库 README 的 Known issues 第一条承认这个下限没有挡住它:

Frontrunning the first deposit may steal 1/4 of the deposit

README 给的步骤是这样的。攻击者先铸 MIN_INITIAL_SHARES 份,再烧掉 MIN_INITIAL_SHARES - 1 份,让总供应量变成 1;然后直接往 Hub 那个 v3 头寸里加 L/2 + 1 的流动性 —— Uniswap 允许任何人给别人的头寸加流动性,这一步不需要任何权限。受害者再存 L 进来,算出来的份额是 L / (L/2 + 1),向下取整等于 1。此时受害者手里 1 份、总供应 2 份,赎回时拿走一半,亏掉 L/4

README 同时说明这在实践中不成问题,而两条理由都在链下:Bunni 官网把「创建代币」和「首笔存入」打包进同一次 multicall,中间插不进交易;bunni-zap 合约提供 sharesMin 参数,份额不够就 revert。也就是说,这个防护不在 Hub 里,在调用方那里。README 最后一句提醒的正是这个 —— 合约集成方得自己知道这件事,否则会亏钱。

withdraw 先烧份额,而整个仓库没有一把重入锁

bunni · src/BunniHub.sol · withdraw(省略 emit)
function withdraw(WithdrawParams calldata params)
external
virtual
override
checkDeadline(params.deadline)
5 collapsed lines
returns (
uint128 removedLiquidity,
uint256 amount0,
uint256 amount1
)
{
IBunniToken shareToken = getBunniToken(params.key);
require(address(shareToken) != address(0), "WHAT");
uint256 currentTotalSupply = shareToken.totalSupply();
(uint128 existingLiquidity, , , , ) = params.key.pool.positions(
7 collapsed lines
keccak256(
abi.encodePacked(
address(this),
params.key.tickLower,
params.key.tickUpper
)
)
);
// burn shares
require(params.shares > 0, "0");
shareToken.burn(msg.sender, params.shares);
// at this point of execution we know param.shares <= currentTotalSupply
// since otherwise the burn() call would've reverted
removedLiquidity = uint128(
FullMath.mulDiv(
existingLiquidity,
params.shares,
currentTotalSupply
)
);
// tokens are now collectable in the pool
(amount0, amount1) = params.key.pool.burn(
params.key.tickLower,
params.key.tickUpper,
removedLiquidity
);
// collect tokens and give to msg.sender
(amount0, amount1) = params.key.pool.collect(
params.recipient,
params.key.tickLower,
params.key.tickUpper,
uint128(amount0),
uint128(amount1)
);
require(
amount0 >= params.amount0Min && amount1 >= params.amount1Min,
"SLIP"
);
}

顺序是对的:先 shareToken.burn,烧完了才按 existingLiquidity × shares / currentTotalSupply 算要撤多少流动性,然后 pool.burn 把流动性拆回代币、pool.collect 直接打给 params.recipient,滑点检查放在最后。份额在任何代币离开合约之前就已经销毁,currentTotalSupply 也是在 burn 之前就读好的。

更值得记的是它没有的东西:withdraw 上没有 nonReentrantBunniHub 也没有继承任何 ReentrancyGuard,整个仓库 grep 不到 reentran 这个词根。谈这类合约时常说的「检查-效果-交互加一把重入锁双保险」,在这份源码里只有前一半。

这不等于有洞:撤资路径上没有向调用方提供的地址做回调,pool.collect 把代币直接打给 params.recipient。但如果 token0 或 token1 是带转账钩子的代币,入口就是敞开的,v1 在自己这一层没有加保护。这一点 README 的 Known issues 里没有提,我也没有找到 Bunni 的公开说明。

返回元组也顺手记一下:(uint128 removedLiquidity, uint256 amount0, uint256 amount1),第一个字段是撤掉的流动性数量,shares 是入参不是返回值。

compound 谁都能调,代价写在 README 的 Known issues 里

bunni · src/BunniHub.sol · compound(节选)
function compound(BunniKey calldata key)
external
virtual
override
5 collapsed lines
returns (
uint128 addedLiquidity,
uint256 amount0,
uint256 amount1
)
{
uint256 protocolFee_ = protocolFee;
// trigger an update of the position fees owed snapshots if it has any liquidity
key.pool.burn(key.tickLower, key.tickUpper, 0);
(, , , uint128 cachedFeesOwed0, uint128 cachedFeesOwed1) = key
.pool
8 collapsed lines
.positions(
keccak256(
abi.encodePacked(
address(this),
key.tickLower,
key.tickUpper
)
)
);
amount0 = cachedFeesOwed0;
amount1 = cachedFeesOwed1;
// 省略:用 slot0 + LiquidityAmounts 先算「这些手续费最多换成多少流动性」,
// 再反推「那么实际只要领多少」,避免领出用不掉的那一侧
(amount0, amount1) = key.pool.collect(
address(this),
key.tickLower,
key.tickUpper,
uint128(amount0),
uint128(amount1)
);
if (protocolFee_ > 0) {
uint256 fee0 = (amount0 * protocolFee_) / WAD;
uint256 fee1 = (amount1 * protocolFee_) / WAD;
// 省略:把 amount0 - fee0 / amount1 - fee1 通过 _addLiquidity 加回池子
} else {
// 省略:把全部手续费通过 _addLiquidity 加回池子
}
// 省略:emit Compound
}

第 14 行 key.pool.burn(key.tickLower, key.tickUpper, 0) 是个惯用手法:撤 0 流动性,什么都不动,但会逼着 v3 把这个头寸的 feesOwed 快照刷一遍,下一行 positions(...) 才读得到最新的累计手续费。

被我省掉的那段算术在做一件不显然的事。两侧手续费的比例通常和当前价格要求的比例对不上,全领出来必然有一侧用不完。所以它先用 getLiquidityForAmounts 算出「这些手续费最多能换成多少流动性」,再用 getAmountsForLiquidity 反推「那么实际只要领这么多」,然后只 collect 这个数。剩下的留在池子里继续挂着,不产生 dust。

compound 没有 checkDeadline,没有 owner 检查,任何地址都能对任意 key 调用。README 的 Known issues 第二条说这是有意的设计选择,并且承认它能被三明治:攻击者先从底层池子买入、再触发 compound、然后卖回。原话是「there’s no perfect solution to it」,给的理由是每次复投的金额通常和复投本身的 gas 成本在同一个数量级,不值得攻击。

protocolFee 从复投金额里抽,上限 50%,抽走的部分留在 Hub 里,owner 用 sweepTokens 取走。整个 BunniHub.sol 452 行,owner 能调的就 setProtocolFeesweepTokens 两个函数,没有暂停开关。

v2 换掉了整个底座,和 v1 没有共用代码

v2 的仓库是 Bunniapp/bunni-v2,建在 Uniswap v4 上(lib/v4-core 是 submodule,Solidity 0.8.26)。v4 hook 本身的机制这里不展开,可以看 Uniswap V4 Hooks 指南

graph TB
subgraph "Bunni V2 Core Architecture"
A[BunniHub] --> B[BunniHook]
B --> C[Liquidity Density Functions]
B --> D[Rehypothecation Engine]
B --> E[am-AMM Auction]
A --> F[BunniToken]
B --> G[BunniZone]
end
subgraph "External Integrations"
D --> H[Aave/Yearn]
E --> I[MEV Capture]
C --> J[Auto-Rebalancing]
end

图里这几个名字在仓库里都能对上号,但各自的分量和直觉不太一样。

LDF 的接口在 src/interfaces/ILiquidityDensityFunction.sol,五个函数:querycomputeSwapcumulativeAmount0cumulativeAmount1isValidParams。natspec 把它定义成一个归一化函数 —— 所有 rounded tick(源码里叫 rick)上的流动性密度加起来等于 1,作者自己拿概率密度函数作类比。src/ldf/ 下有几种现成形状:UniformDistributionGeometricDistributionDoubleGeometricDistributionBuyTheDipGeometricDistribution 等。

再抵押走的是 ERC-4626。src/types/PoolState.sol 里每个池子存着 vault0vault1 两个 vault 地址,填零地址就是关掉这一侧;src/lib/VaultMath.sol 全文只有一个函数,拿 previewRedeem 把 vault 份额折回底层资产。官方文档列的接入对象是 Aave、Yearn、Gearbox、Morpho,并注明不限于这几个。

am-AMM 不是 Bunni 自己写的,BunniHook 直接继承外部库 biddogAmAmm。官方文档说得很直接:拍卖赢家拿走池子的全部交换费收入,而且 v2 支持方向性费率 —— 买和卖可以是两个数。

BunniZone 和流动性无关。它实现的是 Flood 的 IZone 接口,判定逻辑一句话:isWhitelisted[fulfiller] || topBid.manager == fulfiller。自动再平衡以订单形式外包出去,Zone 决定谁有资格来成交这些订单 —— 白名单地址,或者该池当前的 am-AMM manager。

hook 回调这一项容易写错。部署脚本里打开的 flag 是 AFTER_INITIALIZEBEFORE_ADD_LIQUIDITYBEFORE_SWAPBEFORE_SWAP_RETURNS_DELTA,但合约里只实现了 afterInitializebeforeSwap 两个函数。BEFORE_ADD_LIQUIDITY 的 flag 开着却没有对应实现,效果是任何绕过 BunniHub、直接向 v4 PoolManager 加流动性的调用都会 revert —— 这是一道锁,不是一个功能。

至于「恒定 gas」,白皮书的原话是 LDF 让流动性的分布、修改和 swap 都有 constant gas cost,而 v3 的 swap gas「grew linearly w.r.t. the number of ticks crossed」。白皮书没有写大 O 记号,而且自己加了限定:这一切靠的是逆累积量函数(ICAF),几何型 LDF 的 ICAF 能用基本算术在常数时间里算出来,线性型就得动用 Lambert W 函数。所以「恒定」是对特定 LDF 形状成立的,不是对所有形状的承诺。

攻击前 Bunni 在 v4 hook 里的份额确实不小。第三方研究机构 Auditless 在 2025 年 4 月援引 HookRank 的数据,说它占当时被追踪的 hook 交易量约 59%(1.38 亿美元 / 2.36 亿美元)。这是一张快照,不是官方数字,时间也在攻击之前五个月。

840 万美元来自一个取整方向

2025 年 9 月 2 日,v2 被攻击。官方 post-mortem 说损失约 840 万美元,命中两个池子:Unichain 上的 weETH/ETH 和以太坊上的 USDC/USDT。

攻击分三步。第一步闪电贷 300 万 USDT,反复把 USDT 换成 USDC,把现货 tick 推到 5000,池子里 USDC 一侧的活跃余额被压到 28 wei。第二步连做 44 笔极小额提现,每一笔都吃掉一点取整误差,把这 28 wei 压到 4 wei —— 降了 85.7%,而池子记录的总流动性因此错误地掉了 84.4%,从 5.83e16 掉到 9.114e15。第三步再做一笔大额 swap 把价格推到极端,流动性估计的来源在这里切换了,总流动性凭空涨回去,攻击者三明治掉这一跳。官方把它总结成一句:攻击者手工造了一次原子的流动性上涨,然后夹了它。

根因是 BunniHubLogicwithdraw 里更新闲置余额的一行。官方给出的修复只改了一个词:

bunni-v2 · BunniHubLogic.sol · 官方 post-mortem 给出的修复
uint256 newBalance = balance - balance.mulDiv(shares, currentTotalSupply);
uint256 newBalance = balance - balance.mulDivUp(shares, currentTotalSupply);

mulDiv 向下取整。开发时的推理是:把减少量往小了算,闲置余额就往大了算,活跃余额就往小了算;而活跃流动性越低、swap 的价格冲击越大,对池子是有利的,所以这是「安全」的取整方向。post-mortem 对这个推理的评价是 “This assumption was unfortunately wrong”,教训写在同一段的开头:在单次操作里安全的取整方向,放到多次操作里不一定还安全。

官方还纠正了当时流传的两个分析。有人说问题出在 Bunni 的再平衡或者 LDF 平移上,post-mortem 明确写「These analyses were wrong」—— 攻击过程中池子既没有平移也没有再平衡。

有一条很反直觉:最大的那个池子(Unichain 上的 USDC/USD₮0)没被打中。post-mortem 引 Cyfrin 的分析,答案是运气 —— Unichain 上没有足够的闪电贷流动性,而且这个池子有几百万美元的资产被再抵押到 Euler 借了出去,反倒不在能被攻击的范围里。平时被当作额外风险的再抵押,这一次成了保护。

2025 年 10 月 23 日团队宣布关停。公告给的理由很具体:重新安全上线,光审计和监控就要六到七位数的开支,而团队只有 6 个人,付不起。同一条公告里,v2 合约的许可证从 BUSL 改成了 MIT。今天打开那个仓库,README 第一句是:

Important: Bunni v2 has been exploited (see post mortem here), and this repo does not have the relevant issues addressed. Please do not use this code in production as is.

这套设计留下的几笔账

v1 把首笔存入的防护推给了调用方。 MIN_INITIAL_SHARES 只是个下限,README 自己算清楚了抢跑能偷走 1/4,真正拦住它的是官网的 multicall 和 zap 合约的 sharesMin。直接对 Hub 调 deposit 的集成方拿不到这层保护。

v1 没有重入锁。 整个仓库连 ReentrancyGuard 这个词都没有,安全性完全建立在「撤资路径上不回调用户地址」这个前提上。前提成立的时候没问题,换成带转账钩子的代币就得自己重新论证一遍。

permissionless 的 compound 是拿「可被三明治」换来的。 README 承认这一点并且不打算修,理由是复投金额通常和 gas 成本同量级。这个判断依赖于池子不大 —— 它不是一条对任意规模都成立的结论。

hub-spoke 省的是部署成本和授权次数,代价是所有池子共用一个信任点。 v1 的 Hub 不可升级,owner 只有 setProtocolFeesweepTokens 两个开关,出事了既改不了也停不下来。

「恒定 gas」有前提。 白皮书说的是几何型 LDF 的逆累积函数能在常数时间里算出来,线性型要用 Lambert W。把它读成「任何流动性形状的 gas 都恒定」就超出了原文。

取整方向是要连着看的。 v2 那行 mulDiv 单独看无懈可击,问题出在它被连续调用 44 次。审计一个取整方向的时候,光问「这一次偏向谁」不够,还得问「重复一万次之后偏向谁」。

这篇归在 DeFi 协议下。同一主题里最近的另外两篇是 Uniswap V4 Hooks 指南Perpetual Protocol 五:InsuranceFund 和 Vault - 资金安全保障