Sharing another interesting finding that was discovered by Grego AI on the @Balancer V3 LBPMigrationRouter contract at https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/pool-weighted/contracts/lbp/LBPMigrationRouter.sol
NOTE: This contract was not being used in production and no funds were at risk. The Balancer team was kind enough to award us a bounty for an out-of-scope target.
Team has acknowledged and fixed here: https://github.com/balancer/balancer-v3-monorepo/pull/1581
If you would like to engage our security services for your protocol contact @0xriptide or @0xitsgreg to discuss.
Bug report follows …
Summary
LBPMigrationRouter._computeExactAmountsIn() builds the initial balances for each new WeightedPool using raw ERC-20 amounts. When the two LBP tokens have different decimals the price calculation is off by 10^Δdecimals, so Vault.initialize() seeds the pool with a deterministic imbalance. Any account can immediately arbitrage the freshly-created pool and steal almost the entire higher-decimal-side liquidity.
Finding Description
// LBPMigrationRouter.sol
uint256 price = (removeAmountsOut[data.projectTokenIndex] // raw proj, 18-dec
* currentWeights[data.reserveTokenIndex])
.divDown(
removeAmountsOut[data.reserveTokenIndex] // raw reserve, e.g. 6-dec
* currentWeights[data.projectTokenIndex]);
reserveAmountOut = (removeAmountsOut[data.projectTokenIndex]
* migrationWeightReserveToken)
.divDown(price * migrationWeightProjectToken); // uses wrong price
...
_vault.initialize(pool, tokens, exactAmountsIn, ...) // pool starts mis-priced
removeAmountsOut are un-scaled token balances; the function ignores the per-token decimalScalingFactors already available from the Vault.
For a 18-dec / 6-dec pair the computed price is 10¹²× too high, reserveAmountOut 10¹²× too low, and the new pool is born with a 10¹²:1 reserve/project ratio.
Attack Steps
- Watch mempool for LBPMigrationRouter.migrateLiquidity(lbp, …) tx.
- (Optional) call queryMigrateLiquidity() to confirm decimals differ and read the soon-to-be pool address.
- In the same or next block, swap a minimal amount of the over-valued token in the new WeightedPool:Vault.swap(EXACT_IN, pool, projectToken, reserveToken, 1 wei, 0)
- Because reserve/project is 10¹²× too high, the swap returns nearly all reserve tokens.
- Sell the reserve tokens externally; profit ≈ entire reserve side of TVL.
Likelihood (high)
Mixed-decimal LBPs (e.g., 18-dec project token vs 6-dec USDC) are common and migration is a public, single-tx procedure. No permissions are needed; any EOA or bot can back-run the migration and perform one cheap swap. Capital can be flash-borrowed, and profit is strictly positive, so MEV infrastructure will exploit every such migration automatically. Therefore exploitation is virtually certain whenever a heterogeneous-decimal LBP is migrated.
Impact (critical)
The first arbitrage drains almost the entire value of the higher-decimal token from the newly-created WeightedPool (loss ≈ (1 – 10^-Δdec) × weight × TVL, i.e. ~100 % for 18/6 decimals). Liquidity providers migrating from the LBP lose these funds permanently, and the pool is left holding almost only the cheaper asset, defeating its purpose.
Mitigation
Inside _computeExactAmountsIn() scale both removeAmountsOut[i] to 18-decimals before any arithmetic:
(uint256[] memory scale,) = _vault.getPoolTokenRates(lbp);
uint256 projScaled = removeProj.mulDown(scale[projIx]);
uint256 resvScaled = removeResv.mulDown(scale[resvIx]);
Perform the price and amount calculations with the scaled values, then convert the final exactAmountsIn back to raw units by dividing by the same scale[i]. This single change makes the computation decimal-agnostic and eliminates the mis-pricing.