前言

这笔攻击算是亲眼见证了。攻击者绝对不是一个人,后面几乎链上的hackbot都来复制攻击进行分羹了。

发现了很多3笔tx,2笔tx,甚至1笔来攻击和转移的。

原理剖析

这次来讲一下实际合约代码中的调用。
一般我在分析就是这个步骤。当然平时有经验了,文档中的很多步骤都省略了。

这里只涉及漏洞存在地方。

前面的call approve和log事件先不看,这个exp非常像是发现了攻击然后让ai写的。因为非常多的log。

可以看到第一次调用的是:
file-20251106131821231.png
池合约的updateTokenRateCache方法,传入了一个”BPT token”。
我们在代码中看看。
Balancer v2 ‘Composable Stable’ pool 合约:

1
2
3
4
5
6
7
8
function updateTokenRateCache(IERC20 token) external whenNotInVaultContext {
uint256 index = _getTokenIndex(token);

IRateProvider provider = _getRateProvider(index);
_require(address(provider) != address(0), Errors.TOKEN_DOES_NOT_HAVE_RATE_PROVIDER);
uint256 duration = _tokenRateCaches[index].getDuration();
_updateTokenRateCache(index, provider, duration);
}

用到了一个whenNotInVaultContext修饰器。这个修饰器内部会主动对金库执行一次“空操作”ops的 manageUserBalance 调用,用来触发金库的可重入检查.这个和漏洞没有关系。
其中也调用了_updateTokenRateCache。

1
2
3
4
5
6
7
8
9
10
11
12
function _updateTokenRateCache(
uint256 index,
IRateProvider provider,
uint256 duration
) internal virtual {
uint256 rate = provider.getRate();
bytes32 cache = _tokenRateCaches[index];

_tokenRateCaches[index] = cache.updateRateAndDuration(rate, duration);

emit TokenRateCacheUpdated(index, rate);
}

所以这个调用就是:先空操作进入 Vault 做防重入检查,再调用 provider.getRate() 刷新指定 token 的价格率缓存缓存,然后 emit 事件。

继续往后面看:
中途的单call忽略不看.
file-20251106131821231 1.png
可以看到这里call了金库合约的batchSwap分别传入了几个参数,现在来分析重点的
直接看金库合约的batchSwap合约代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function batchSwap(
SwapKind kind,
BatchSwapStep[] memory swaps,
IAsset[] memory assets,
FundManagement memory funds,
int256[] memory limits,
uint256 deadline
)
external
payable
override
nonReentrant
whenNotPaused
authenticateFor(funds.sender)
returns (int256[] memory assetDeltas)
{
// The deadline is timestamp-based: it should not be relied upon for sub-minute accuracy.
// solhint-disable-next-line not-rely-on-time
_require(block.timestamp <= deadline, Errors.SWAP_DEADLINE);

InputHelpers.ensureInputLengthMatch(assets.length, limits.length);

// Perform the swaps, updating the Pool token balances and computing the net Vault asset deltas.
assetDeltas = _swapWithPools(swaps, assets, funds, kind);

// Process asset deltas, by either transferring assets from the sender (for positive deltas) or to the recipient
// (for negative deltas).
uint256 wrappedEth = 0;
for (uint256 i = 0; i < assets.length; ++i) {
IAsset asset = assets[i];
int256 delta = assetDeltas[i];
_require(delta <= limits[i], Errors.SWAP_LIMIT);

if (delta > 0) {
uint256 toReceive = uint256(delta);
_receiveAsset(asset, toReceive, funds.sender, funds.fromInternalBalance);

if (_isETH(asset)) {
wrappedEth = wrappedEth.add(toReceive);
}
} else if (delta < 0) {
uint256 toSend = uint256(-delta);
_sendAsset(asset, toSend, funds.recipient, funds.toInternalBalance);
}
}

// Handle any used and remaining ETH.
_handleRemainingEth(wrappedEth);
}

可以看到比较关键的点,同时备注可以翻译一下:

1
2
// 执行兑换操作,更新资金池代币余额,并计算金库资产净值变化。
assetDeltas = _swapWithPools(swaps, assets, funds, kind);

看一下swapWithPools的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
function _swapWithPools(
BatchSwapStep[] memory swaps,
IAsset[] memory assets,
FundManagement memory funds,
SwapKind kind
) private returns (int256[] memory assetDeltas) {
assetDeltas = new int256[](assets.length);

// These variables could be declared inside the loop, but that causes the compiler to allocate memory on each
// loop iteration, increasing gas costs.
BatchSwapStep memory batchSwapStep;
IPoolSwapStructs.SwapRequest memory poolRequest;

// These store data about the previous swap here to implement multihop logic across swaps.
IERC20 previousTokenCalculated;
uint256 previousAmountCalculated;

for (uint256 i = 0; i < swaps.length; ++i) {
batchSwapStep = swaps[i];

bool withinBounds = batchSwapStep.assetInIndex < assets.length &&
batchSwapStep.assetOutIndex < assets.length;
_require(withinBounds, Errors.OUT_OF_BOUNDS);

IERC20 tokenIn = _translateToIERC20(assets[batchSwapStep.assetInIndex]);
IERC20 tokenOut = _translateToIERC20(assets[batchSwapStep.assetOutIndex]);
_require(tokenIn != tokenOut, Errors.CANNOT_SWAP_SAME_TOKEN);

// Sentinel value for multihop logic
if (batchSwapStep.amount == 0) {
// When the amount given is zero, we use the calculated amount for the previous swap, as long as the
// current swap's given token is the previous calculated token. This makes it possible to swap a
// given amount of token A for token B, and then use the resulting token B amount to swap for token C.
_require(i > 0, Errors.UNKNOWN_AMOUNT_IN_FIRST_SWAP);
bool usingPreviousToken = previousTokenCalculated == _tokenGiven(kind, tokenIn, tokenOut);
_require(usingPreviousToken, Errors.MALCONSTRUCTED_MULTIHOP_SWAP);
batchSwapStep.amount = previousAmountCalculated;
}

// Initializing each struct field one-by-one uses less gas than setting all at once
poolRequest.poolId = batchSwapStep.poolId;
poolRequest.kind = kind;
poolRequest.tokenIn = tokenIn;
poolRequest.tokenOut = tokenOut;
poolRequest.amount = batchSwapStep.amount;
poolRequest.userData = batchSwapStep.userData;
poolRequest.from = funds.sender;
poolRequest.to = funds.recipient;
// The lastChangeBlock field is left uninitialized

uint256 amountIn;
uint256 amountOut;
(previousAmountCalculated, amountIn, amountOut) = _swapWithPool(poolRequest);

previousTokenCalculated = _tokenCalculated(kind, tokenIn, tokenOut);

// Accumulate Vault deltas across swaps
assetDeltas[batchSwapStep.assetInIndex] = assetDeltas[batchSwapStep.assetInIndex].add(amountIn.toInt256());
assetDeltas[batchSwapStep.assetOutIndex] = assetDeltas[batchSwapStep.assetOutIndex].sub(
amountOut.toInt256()
);
}
}

可以看到这里好玩的是会循环每个 BatchSwapStep,构造 SwapRequest。
注意:
如果swapRequest.tokenOut发送的涉及 BPT 那么去 _swapWithBpt (单币加入/退出)
如果不涉及BPT,去_swapGivenOut(漏洞存在点)

(在调用中也可以发现传入了非常多的swap)
file-20251106131821231 2.png

然后调用  swapWithPool
(previousAmountCalculated, amountIn, amountOut) = _swapWithPool(poolRequest);

看swapWithPool方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function _swapWithPool(IPoolSwapStructs.SwapRequest memory request)
private
returns (
uint256 amountCalculated,
uint256 amountIn,
uint256 amountOut
)
{
// Get the calculated amount from the Pool and update its balances
address pool = _getPoolAddress(request.poolId);
PoolSpecialization specialization = _getPoolSpecialization(request.poolId);

if (specialization == PoolSpecialization.TWO_TOKEN) {
amountCalculated = _processTwoTokenPoolSwapRequest(request, IMinimalSwapInfoPool(pool));
} else if (specialization == PoolSpecialization.MINIMAL_SWAP_INFO) {
amountCalculated = _processMinimalSwapInfoPoolSwapRequest(request, IMinimalSwapInfoPool(pool));
} else {
// PoolSpecialization.GENERAL
amountCalculated = _processGeneralPoolSwapRequest(request, IGeneralPool(pool));
}

(amountIn, amountOut) = _getAmounts(request.kind, request.amount, amountCalculated);
emit Swap(request.poolId, request.tokenIn, request.tokenOut, amountIn, amountOut);
}

里面有3条路径:

1
2
3
4
5
6
7
8
if (specialization == PoolSpecialization.TWO_TOKEN) {
amountCalculated = _processTwoTokenPoolSwapRequest(request, IMinimalSwapInfoPool(pool));
} else if (specialization == PoolSpecialization.MINIMAL_SWAP_INFO) {
amountCalculated = _processMinimalSwapInfoPoolSwapRequest(request, IMinimalSwapInfoPool(pool));
} else {
// PoolSpecialization.GENERAL
amountCalculated = _processGeneralPoolSwapRequest(request, IGeneralPool(pool));
}

前两条
_processTwoTokenPoolSwapRequest _processMinimalSwapInfoPoolSwapRequest
无论怎么都会走到 _callMinimalSwapInfoPoolOnSwapHook
file-20251106131821231 3.png
前两条进入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function _callMinimalSwapInfoPoolOnSwapHook(
IPoolSwapStructs.SwapRequest memory request,
IMinimalSwapInfoPool pool,
bytes32 tokenInBalance,
bytes32 tokenOutBalance
)
internal
returns (
bytes32 newTokenInBalance,
bytes32 newTokenOutBalance,
uint256 amountCalculated
)
{
uint256 tokenInTotal = tokenInBalance.total();
uint256 tokenOutTotal = tokenOutBalance.total();
request.lastChangeBlock = Math.max(tokenInBalance.lastChangeBlock(), tokenOutBalance.lastChangeBlock());

// Perform the swap request callback, and compute the new balances for 'token in' and 'token out' after the swap
amountCalculated = pool.onSwap(request, tokenInTotal, tokenOutTotal);
(uint256 amountIn, uint256 amountOut) = _getAmounts(request.kind, request.amount, amountCalculated);

newTokenInBalance = tokenInBalance.increaseCash(amountIn);
newTokenOutBalance = tokenOutBalance.decreaseCash(amountOut);
}

第三条走进去`_processGeneralPoolSwapRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
function _processGeneralPoolSwapRequest(IPoolSwapStructs.SwapRequest memory request, IGeneralPool pool)
private
returns (uint256 amountCalculated)
{
bytes32 tokenInBalance;
bytes32 tokenOutBalance;

// We access both token indexes without checking existence, because we will do it manually immediately after.
EnumerableMap.IERC20ToBytes32Map storage poolBalances = _generalPoolsBalances[request.poolId];
uint256 indexIn = poolBalances.unchecked_indexOf(request.tokenIn);
uint256 indexOut = poolBalances.unchecked_indexOf(request.tokenOut);

if (indexIn == 0 || indexOut == 0) {
// The tokens might not be registered because the Pool itself is not registered. We check this to provide a
// more accurate revert reason.
_ensureRegisteredPool(request.poolId);
_revert(Errors.TOKEN_NOT_REGISTERED);
}

// EnumerableMap stores indices *plus one* to use the zero index as a sentinel value - because these are valid,
// we can undo this.
indexIn -= 1;
indexOut -= 1;

uint256 tokenAmount = poolBalances.length();
uint256[] memory currentBalances = new uint256[](tokenAmount);

request.lastChangeBlock = 0;
for (uint256 i = 0; i < tokenAmount; i++) {
// Because the iteration is bounded by `tokenAmount`, and no tokens are registered or deregistered here, we
// know `i` is a valid token index and can use `unchecked_valueAt` to save storage reads.
bytes32 balance = poolBalances.unchecked_valueAt(i);

currentBalances[i] = balance.total();
request.lastChangeBlock = Math.max(request.lastChangeBlock, balance.lastChangeBlock());

if (i == indexIn) {
tokenInBalance = balance;
} else if (i == indexOut) {
tokenOutBalance = balance;
}
}

// Perform the swap request callback and compute the new balances for 'token in' and 'token out' after the swap
amountCalculated = pool.onSwap(request, currentBalances, indexIn, indexOut);
(uint256 amountIn, uint256 amountOut) = _getAmounts(request.kind, request.amount, amountCalculated);
tokenInBalance = tokenInBalance.increaseCash(amountIn);
tokenOutBalance = tokenOutBalance.decreaseCash(amountOut);

// Because no tokens were registered or deregistered between now or when we retrieved the indexes for
// 'token in' and 'token out', we can use `unchecked_setAt` to save storage reads.
poolBalances.unchecked_setAt(indexIn, tokenInBalance);
poolBalances.unchecked_setAt(indexOut, tokenOutBalance);
}

都有计算amountCalculated
无论那一条都会反复回调进入onSwap.

看一下debug内容(这里我没有频繁debug看,因为实在太卡了),这里的池子通过第三条进入onSwap:
`amountCalculated = pool.onSwap(request, currentBalances, indexIn, indexOut);
file-20251106131821231 4.png
file-20251106131821231 5.png

关于不同池子可以看:备注很详细,这里不多做解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Pools
//
// There are three specialization settings for Pools, which allow for cheaper swaps at the cost of reduced
// functionality:
//
// - General: no specialization, suited for all Pools. IGeneralPool is used for swap request callbacks, passing the
// balance of all tokens in the Pool. These Pools have the largest swap costs (because of the extra storage reads),
// which increase with the number of registered tokens.
//
// - Minimal Swap Info: IMinimalSwapInfoPool is used instead of IGeneralPool, which saves gas by only passing the
// balance of the two tokens involved in the swap. This is suitable for some pricing algorithms, like the weighted
// constant product one popularized by Balancer V1. Swap costs are smaller compared to general Pools, and are
// independent of the number of registered tokens.
//
// - Two Token: only allows two tokens to be registered. This achieves the lowest possible swap gas cost. Like
// minimal swap info Pools, these are called via IMinimalSwapInfoPool.
1
2
3
4
5
6
// Perform the swap request callback and compute the new balances for 'token in' and 'token out' after the swap
// 执行兑换请求回调,并计算兑换后“token in”和“token out”的新余额
amountCalculated = pool.onSwap(request, currentBalances, indexIn, indexOut);
(uint256 amountIn, uint256 amountOut) = _getAmounts(request.kind, request.amount, amountCalculated);
tokenInBalance = tokenInBalance.increaseCash(amountIn);
tokenOutBalance = tokenOutBalance.decreaseCash(amountOut);

来看onSwap方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function onSwap(
SwapRequest memory swapRequest,
uint256[] memory balances,
uint256 indexIn,
uint256 indexOut
) external override onlyVault(swapRequest.poolId) returns (uint256) {
_beforeSwapJoinExit();

_validateIndexes(indexIn, indexOut, _getTotalTokens());
uint256[] memory scalingFactors = _scalingFactors();//缩放因子缩放

return
swapRequest.kind == IVault.SwapKind.GIVEN_IN
? _swapGivenIn(swapRequest, balances, indexIn, indexOut, scalingFactors)
: _swapGivenOut(swapRequest, balances, indexIn, indexOut, scalingFactors);
}

可以很容易很出来这个过程:仅允许金库调用 → 做预检查 → 取缩放因子(含价格率)→ 先把余额与金额“放大”到统一精度 → 调用子类的定价函数 → 再把结果“缩小”返回。

进入具体 _swapGivenIn/_swapGivenOut 之前,先拿缩放因子(_scalingFactors),再对余额做“上缩放”,

在debug中也可以清楚看到:
file-20251106131821231 6.png

继续debug可以看到:
file-20251106131821231 7.png

通过IVault.SwapKind.GIVEN_IN走进了ComposableStablePool.sol的_swapGivenOut。

file-20251106131821231 8.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* @dev Override this hook called by the base class `onSwap`, to check whether we are doing a regular swap,
* or a swap involving BPT, which is equivalent to a single token join or exit. Since one of the Pool's
* tokens is the preminted BPT, we need to handle swaps where BPT is involved separately.
*
* At this point, the balances are unscaled. The indices and balances are coming from the Vault, so they
* refer to the full set of registered tokens (including BPT).
*
* If this is a swap involving BPT, call `_swapWithBpt`, which computes the amountOut using the swapFeePercentage
* and charges protocol fees, in the same manner as single token join/exits. Otherwise, perform the default
* processing for a regular swap.
*/
function _swapGivenOut(
SwapRequest memory swapRequest,
uint256[] memory registeredBalances,
uint256 registeredIndexIn,
uint256 registeredIndexOut,
uint256[] memory scalingFactors
) internal virtual override returns (uint256) {
return
(swapRequest.tokenIn == IERC20(this) || swapRequest.tokenOut == IERC20(this))
? _swapWithBpt(swapRequest, registeredBalances, registeredIndexIn, registeredIndexOut, scalingFactors)
: super._swapGivenOut(
swapRequest,
registeredBalances,
registeredIndexIn,
registeredIndexOut,
scalingFactors
);
}

看注释:”余额未进行缩放。索引和余额来自金库”
这里是涉及BPT的情况
继续跟进走入了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function _swapWithBpt(
SwapRequest memory swapRequest,
uint256[] memory registeredBalances,
uint256 registeredIndexIn,
uint256 registeredIndexOut,
uint256[] memory scalingFactors
) private returns (uint256) {
bool isGivenIn = swapRequest.kind == IVault.SwapKind.GIVEN_IN;

_upscaleArray(registeredBalances, scalingFactors);
swapRequest.amount = _upscale(
swapRequest.amount,
scalingFactors[isGivenIn ? registeredIndexIn : registeredIndexOut]
);

 进入了upscaleArray方法。

1
2
3
4
5
6
7
8
9
10
11
12
/**
* @dev Same as `_upscale`, but for an entire array. This function does not return anything, but instead *mutates*
* the `amounts` array.
*/
function _upscaleArray(uint256[] memory amounts, uint256[] memory scalingFactors) internal pure {
uint256 length = amounts.length;
InputHelpers.ensureInputLengthMatch(length, scalingFactors.length);

for (uint256 i = 0; i < length; ++i) {
amounts[i] = FixedPoint.mulDown(amounts[i], scalingFactors[i]);
}
}

随后进入了这个ensureInputLengthMatch后进入了数学库:
我认为这里出现了bug,因为直接通过项目代码去追进应该是步入FixedPoint.mulDown中而非数学库中。这里也会出现精度损失,我们后面一起说。
注意,这个是用来批量处理数组,没有输出(原地修改 amounts 数组)

1
2
3
4
5
function mul(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a * b;
_require(a == 0 || c / a == b, Errors.MUL_OVERFLOW);
return c;
}

出bug了,后面用tenderly演示
file-20251106131821232.png

我上述说过

1
2
3
注意:
如果swapRequest.tokenOut发送的涉及 BPT 那么去` _swapWithBpt` (单币加入/退出)
如果不涉及BPT,去`_swapGivenOut`(漏洞存在点)

使用tenderly走不涉及BPT的情况:

使用tenderly继续往下走
https://dashboard.tenderly.co/tx/0xd78e5d0565e4762e5b08a573103e8d8830aa41247aff68d35e1b733fde7d7165?trace=0.0.2675.4.210.2.15.10.0.1

其他走入和上述一样。

file-20251106131821232 1.png
注意看这里的_upscale
file-20251106131821232 2.png
其中FixdPoint.mulDown实现:

1
2
3
4
5
6
7
function mulDown(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 product = a * b;
_require(a == 0 || product / a == b, Errors.MUL_OVERFLOW);

return product / ONE;
//uint256 internal constant ONE = 1e18; // 18 decimal places
}

根据传入参数:

1
2
3
4
**>>>** print(27547*1049119176449562704)
28900085953656103807088
**>>>** print(27547*1049119176449562704/1e18)
28900.085953656104

可以看到进行了舍入操作。

一般会想。这么小的数字不会影响什么。
但是如果amount是比较小的数字就会影响很多。
所以导致了精度误差。目前这里代码哪里错误就都了解了。

了解一下这个协议。现在来总结一下:

阶段 1:降低池子流动性储备

目标:通过 BPT 兑换流动性代币,将池子流动性降至极低水平

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function _swapWithBpt(
SwapRequest memory swapRequest,
uint256[] memory registeredBalances,
uint256 registeredIndexIn,
uint256 registeredIndexOut,
uint256[] memory scalingFactors
) private returns (uint256) {
bool isGivenIn = swapRequest.kind == IVault.SwapKind.GIVEN_IN;

_upscaleArray(registeredBalances, scalingFactors);
swapRequest.amount = _upscale(
swapRequest.amount,
scalingFactors[isGivenIn ? registeredIndexIn : registeredIndexOut]
);

攻击者使用大量 BPT 换出流动性代币。
这里可以看到:

1
2
3
4
5
6
7
8
9
10
11
function _scalingFactors() internal view virtual override returns (uint256[] memory) {
// There is no need to check the arrays length since both are based on `_getTotalTokens`
uint256 totalTokens = _getTotalTokens();
uint256[] memory scalingFactors = new uint256[](totalTokens);

for (uint256 i = 0; i < totalTokens; ++i) {
scalingFactors[i] = _getScalingFactor(i).mulDown(_getTokenRate(i));
}

return scalingFactors;
}

也走了mulDown:
所以由于汇率存在,计算过程中必然产生小数,使用 mulDown 会截断。
这一系列过程导致池子储备余额被低估 → BPT 价格被低估(BPT 价格 = 不变值 D / 总供应量)
关于BPT价格计算自己可以看代码。

阶段 2:准备小额互换

目标:通过流动性代币的互换,为后续精确控制小额互换做准备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function _swapGivenOut(
SwapRequest memory swapRequest,
uint256[] memory balances,
uint256 indexIn,
uint256 indexOut,
uint256[] memory scalingFactors
) internal virtual returns (uint256) {
_upscaleArray(balances, scalingFactors);
swapRequest.amount = _upscale(swapRequest.amount, scalingFactors[indexOut]);

uint256 amountIn = _onSwapGivenOut(swapRequest, balances, indexIn, indexOut);

// amountIn tokens are entering the Pool, so we round up.
amountIn = _downscaleUp(amountIn, scalingFactors[indexIn]);

// Fees are added after scaling happens, to reduce the complexity of the rounding direction analysis.
return _addSwapFeeAmount(amountIn);
}

阶段 3:精确小额互换(触发精度损失)

1
2
3
4
5
6
7
function _upscale(uint256 amount, uint256 scalingFactor) internal pure returns (uint256) {
// Upscale rounding wouldn't necessarily always go in the same direction: in a swap for example the balance of
// token in should be rounded up, and that of token out rounded down. This is the only place where we round in
// the same direction for all amounts, as the impact of this rounding is expected to be minimal (and there's no
// rounding error unless `_scalingFactor()` is overriden).
return FixedPoint.mulDown(amount, scalingFactor);
}

这里产生精度损失导致误差。

后续继续步入可以看到走到了_onRegularSwap函数中。由于上述的误差,所以导致计算出的不变值 D 被低估。

随后就是恢复流动性–重复导致误差积累越来越多–换回 BPT 并结算。

让AI总结一下:

关键代码位置总结

  1. 精度损失发生点:
  • _upscaleArray → FixedPoint.mulDown (余额缩放)
  • _upscale → FixedPoint.mulDown (金额缩放)
  1. 精度损失传递:
  • 缩放后的余额 → StableMath._calculateInvariant → 不变值 D 被低估
  • 被低估的 D → StableMath._calcInGivenOut → 输入量被低估
  1. 误差累积机制:
  • 批量互换在同一交易中顺序执行
  • 每次交换后,Vault 更新余额
  • 后续交换基于已包含误差的余额继续计算

POC

https://github.com/DK27ss/BalancerV2-128M-PoC