Grego AI | Balancer V3: Decimal mismatch allows full reserve drain

Balancer

V3 LBP Migration Router

Balancer V3: Decimal mismatch allows full reserve drain

A critical Balancer V3 finding where mixed token decimals could misprice a newly migrated liquidity pool by a factor of 10^12.

Failure path

The router priced tokens using raw ERC-20 amounts. An 18-decimal and 6-decimal pair mispriced the pool by a factor of 10^12, letting a backrunner trade a minimal amount for nearly all reserve tokens.

Impact and conditions

Critical if deployed. The contract was not in production and no funds were at risk; Balancer still awarded a bounty for the out-of-scope report.

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

  1. Watch mempool for LBPMigrationRouter.migrateLiquidity(lbp, …) tx.
  2. (Optional) call queryMigrateLiquidity() to confirm decimals differ and read the soon-to-be pool address.
  3. 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)
  4. Because reserve/project is 10¹²× too high, the swap returns nearly all reserve tokens.
  5. 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.