Single-sided BPool joins drain mOCEAN via SideStaking

Single-sided BPool joins drain mOCEAN via SideStaking

The weak point sits in the join and exit maths, not in the idea of a pool clone itself. joinswapExternAmountIn mints pool shares from one-sided base-token input, then SideStaking mirrors part of that action by staking the paired datatoken and minting matching controller shares. That extra mirroring changes the accounting path the pricing formula expects. A function that looks harmless on its own can become non-neutral once another contract copies the flow beside it.

exitswapPoolAmountIn then reads the pool share state back through the same broken lens. The exit maths doubles poolAmountIn during redemption, so the payout no longer matches the paired join in a clean inverse way. In practice, the attacker can enter through a single-sided join, let SideStaking do its matching work, then exit against a pool-share figure that has already been distorted. That is where the drain comes from. The pricing logic still runs, but it no longer describes the same state twice.

joinswapExternAmountIn mints against one side, then SideStaking adds the other half

The join path accepts a single token side and calculates pool output from that input alone. SideStaking then checks whether staking is allowed, stakes the datatoken, and mints controller shares in step with the join. The attacker only funds one side, yet the pool state records a mirrored movement that was not funded in the same way.

That breaks the economic boundary the join formula assumes. calcPoolOutGivenSingleIn halves newPoolSupply inside the calculation, which is fine only if the rest of the flow remains consistent. Once SideStaking is in play, the join is no longer a one-sided deposit in the economic sense. It becomes a paired state change with mismatched inputs.

exitswapPoolAmountIn reads doubled pool shares and pays out too much

The exit path is worse because it turns the distorted share state back into mOCEAN. calcSingleOutGivenPoolIn doubles poolAmountIn, so the redemption side treats the exit as if more pool share value had been committed than really was. That makes the payout larger than the join would justify on a reversible path.

The result is a non-inverse pair of functions. One side mints under one assumption, the other redeems under another. Once those paths are chained with SideStaking, the accounting gap stops being cosmetic and starts paying out real token balance.

The Polygon exploit loop only worked because flash liquidity kept the cycle alive

The attacker did not need long-lived capital. A Uniswap V2 pair for mOCEAN and MegaDoge provided a flash-borrowed 1,261.592155 mOCEAN, enough to run the join and exit cycle across multiple clone pools in a single transaction. The borrowed amount was repaid, and the surplus mOCEAN stayed behind. That pattern matters because the exploit depended on repetition, not a single broken call.

Eight clone pools shared one BPool implementation through minimal proxies. The same logic ran through pool after pool, with the same accounting skew carried forward. Reported activity showed 65 joinswapExternAmountIn calls and 40 exitswapPoolAmountIn calls, with matching SideStaking activity for stake and unstake checks. The first joins on one pool were small, then the exits burned far larger pool-share units, which is what a drain looks like when the numbers are still pretending to be balanced.

Eight clone pools, one shared BPool implementation, and repeated account skew

The clone setup made the flaw easy to reuse. Each proxy delegated into the same BPool implementation, so the same join and exit maths applied across all of them. Once the attacker found a path that skewed accounting in one pool, the same path worked in the others without any fresh bug to hunt down.

That matters for accounting bugs because repetition turns small mismatches into profit. A single bad join might look like noise. Eight pools, each fed through the same asymmetric join and exit sequence, turn noise into a pattern. The numbers line up badly in the same direction every time.

The call trace shows the same join and exit paths being driven over and over

The trace is blunt. joinswapExternAmountIn, canStake, Stake, exitswapPoolAmountIn, canUnStake, UnStake, then back again. The exploit runner kept pushing the same state transition until the pool-share accounting gave out enough mOCEAN to make the cycle worthwhile.

Flash liquidity only mattered because it kept the loop alive inside one transaction. Without it, the attacker would have needed more permanent capital to complete the same repeated sequence. With it, the exploit became a short-lived accounting machine: borrow, skew, redeem, repay, keep the difference.

Close the gap in pool-share accounting before it becomes a drain

Single-sided join and exit paths need to be tested with SideStaking active, not in isolation. The maths can look fine in a standalone unit test and still fail once another contract mirrors stake and unstake calls. If mirrored flows change pool supply, token balances, or share counts, the join and exit paths must still come back to the same economic state.

The practical check is simple enough: a mirrored stake and unstake cycle should leave balances reversible in value terms, not just in function calls. If a join mints shares, then a matched exit should not return more base token than the join can explain after all mirrored actions are accounted for. That is the boundary this exploit crossed.

Test the single-sided join and exit paths with SideStaking in place, not in isolation

Test the exact call sequence the pool will face in production. joinswapExternAmountIn should be exercised with SideStaking enabled, then followed by the matching exit path under the same mirrored conditions. Standalone maths is too polite here. The failure only appears when the controller contract adds its own stake and unstake calls.

The test should compare economic input and output across the full path, not just raw token movements in one contract. If the join mints pool shares for one actor and the controller mirrors part of that state, the exit test must verify that the pool-share accounting still closes cleanly.

Check that mirrored stake and unstake calls leave balances economically reversible

A good boundary is boring: after a mirrored join and exit, nothing should have moved except temporary liquidity. If the controller’s mirrored stake changes the supply basis, the exit function needs to account for that same change or reject the path. Anything else leaves room for a drain that only appears after repeated cycles.

The fix belongs in the accounting model, not in the flash-liquidity layer. Remove the asymmetry between join minting and exit redemption, or make the mirrored SideStaking path part of the same reversible calculation. If the numbers do not close, the pool is already pretending to be balanced.

Related posts

Grafana | v13.2.2

Grafana v13 2 2: security fixes CVEs 2026 15815, 76154, 79656, restores v2 query var refresh, fixes provisioning UID collision, release 2026 09 15, upgrade now

Authelia | v4.39.27

Authelia v43927: fixes consent redirect status, preserves query for Envoy authz, prevents duplicate OIDC consent, Docker images published

Immich | v3.2.2

Immich v3.2.2: fixes face reassign bug to skip faces belonging to other users, low risk patch published Sep 15 2026, upgrade recommended for multiuser setups