Originally published at How One Wei of Shares Drained 14,838 DAI: A Uniswap V4 Vault Exploit.
On October 9, 2026, a transaction on Polygon exposed a simple but dangerous accounting flaw in a liquidity vault. The attacker held only one minimal share unit, yet withdrew approximately 14,838.47 DAI from the contract.
This was not a reentrancy exploit, nor did it depend on an obscure arithmetic trick. The failure sat inside what looked like a harmless fallback branch: whenever the strategy reported zero liquidity, the vault transferred its entire token balance to the current redeemer.
Panda Security’s review of the transaction and source code found a clean four-step path. The attacker acquired one share unit, manipulated the pool so that reposition() produced zero liquidity, then called withdraw(). Instead of calculating a proportional payout, the vault emptied its DAI and USDC balances into the attacker’s contract.
Incident overview
| Item | Details |
|---|---|
| Network | Polygon PoS |
| Exploit time | October 9, 2026, 02:23:42 UTC |
| Attacker EOA | 0x59Ae3c8d...B64e9884B |
| Affected vault | 0x048E8831...e2AF2e5A0 |
| Exploit contract | 0xc2C6F4F7E1425bE1DCB996175Cf7852bF53c23E2 |
| Key transaction | 0x5b795dc5...e04a7ff2 |
| Amount sent by the vault | 14,838.466608989135865711 DAI |
| Final transfer to attacker EOA | Approximately 14,813.548248 USDC |
The amount removed from the vault and the attacker’s final proceeds are not identical. The vault paid out roughly 14,838.47 DAI. After swaps, repayment of temporary liquidity, and execution costs, approximately 14,813.55 USDC reached the attacker’s EOA.
The vulnerable code
The flaw is located in the zero-liquidity branch of _withdraw():
function _withdraw(
uint256 shareAmount,
address recipient,
bool addDust
) internal {
if (liquidity == 0) {
uint256 daiBal = DAI.balanceOf(address(this));
uint256 usdcBal = USDC.balanceOf(address(this));
if (daiBal > 0) DAI.transfer(recipient, daiBal);
if (usdcBal > 0) USDC.transfer(recipient, usdcBal);
emit Withdrawn(recipient, shareAmount, daiBal, usdcBal);
return;
}
// The normal path calculates a proportional withdrawal.
}
Under normal conditions, the vault calculates how much liquidity belongs to the redeemer:
uint256 currentTotalShares = totalShares + shareAmount;
uint128 toRemove = uint128(
uint256(liquidity) * shareAmount / currentTotalShares
);
That proportional calculation disappears when liquidity == 0. The vault simply reads its DAI and USDC balances and transfers everything to the caller.
The code incorrectly treats these two states as equivalent:
No capital is currently deployed as liquidity
!=
The current redeemer owns every idle asset
That mistaken assumption is the root cause of the loss.
How the attack worked
Step 1: Acquire one share with one wei of DAI
The transaction’s Deposited event records:
daiAmount = 1
sharesIssued = 1
DAI uses 18 decimals. The value 1 therefore means one wei of DAI, not one full DAI.
The economic cost was negligible, but this was enough to satisfy the withdrawal checks:
if (shareAmount == 0) revert ZeroAmount();
if (shares[msg.sender] < shareAmount) revert InsufficientShares();
Step 2: Move the pool price and unlock permissionless repositioning
The vault allows an ordinary account to call reposition() when the current tick has moved outside the active range:
if (msg.sender != minter) {
int24 ct = _getCurrentTick();
if (ct >= tickLower && ct < tickUpper) {
revert PositionInRange();
}
}
Using temporary liquidity, the attacker executed a large swap in the Uniswap V4 pool and pushed the current tick outside the vault’s existing range. That made the public reposition() path available.
Price manipulation was only the trigger. The deeper problem was that an externally influenced strategy operation could place the vault into a state that its withdrawal accounting did not handle safely.
Step 3: Make reposition() store zero liquidity
The internal _reposition() function removes the old position, leaves the withdrawn assets in the vault, and calculates the liquidity for a new position:
uint128 newLiquidity;
if (required1 <= amount1) {
newLiquidity = liq0;
} else {
newLiquidity = LiquidityAmounts.getLiquidityForAmount1(
s.sqrtPriceLower,
s.sqrtPriceX96,
amount1
);
}
if (newLiquidity > 0) {
_modifyLiquidity(
s.newTickLower,
s.newTickUpper,
int128(newLiquidity)
);
}
liquidity = newLiquidity;
In the exploit transaction, the Repositioned event recorded:
newTickLower = 276324
newTickUpper = 276327
newLiquidity = 0
The old position had already been removed, leaving roughly 14,838 DAI at the vault address. No replacement position was created. The vault was now in the exact state required by the vulnerable branch:
liquidity == 0
vault DAI balance > 14,838 DAI
attacker shares == 1
Step 4: Redeem one share and empty the vault
The exploit contract immediately redeemed the single minimal share unit.
Because liquidity was zero, _withdraw() transferred the entire DAI balance to the exploit contract. The on-chain Withdrawn event shows:
sharesBurned = 1
daiOut = 14838466608989135865711
usdcOut = 0
With DAI’s 18 decimals, that is:
14,838.466608989135865711 DAI
The attacker paid one wei of DAI for the vault share and received idle assets that belonged collectively to all shareholders.
Why this bug is easy to miss
Read in isolation, the branch looks like a sensible convenience: if no LP position exists, return the tokens held by the vault.
The flaw appears only when the full state transition is considered:
-
reposition()can remove all liquidity from the old position. - The new-liquidity calculation can return zero.
- The withdrawn assets remain at the vault address.
- Existing user shares remain valid.
-
_withdraw()treats zero strategy liquidity as permission to pay one user the entire balance.
At Panda Security, we view this as a state-accounting failure rather than a single bad if statement. Vault reviews must track whether assets, shares, and deployed positions preserve the same ownership relationship across every state transition.
This vault effectively maintained three ledgers:
- the shareholder ledger:
sharesandtotalShares; - the asset ledger: idle DAI and USDC balances;
- the strategy ledger:
liquiditydeployed in Uniswap V4.
During the exploit, the strategy ledger fell to zero while both the asset ledger and shareholder ledger remained non-zero. The withdrawal function selected its payout logic using only the strategy ledger, allowing the three ledgers to diverge.
How to fix it properly
1. Always distribute idle assets proportionally
The minimum repair is to preserve the total supply before burning shares and use it to calculate the redeemer’s portion of every idle balance:
function _withdrawIdleAssets(
uint256 shareAmount,
uint256 totalSharesBefore,
address recipient
) internal {
uint256 daiBal = DAI.balanceOf(address(this));
uint256 usdcBal = USDC.balanceOf(address(this));
uint256 daiOut = daiBal * shareAmount / totalSharesBefore;
uint256 usdcOut = usdcBal * shareAmount / totalSharesBefore;
if (daiOut > 0) DAI.safeTransfer(recipient, daiOut);
if (usdcOut > 0) USDC.safeTransfer(recipient, usdcOut);
}
Only a user burning all outstanding shares should be able to receive the vault’s entire balance.
2. Do not allow repositioning to silently produce zero liquidity
If the old position contained assets but the new calculation produces zero liquidity, the transaction should revert:
if (newLiquidity == 0 && totalShares > 0) {
revert ZeroLiquidityAfterReposition();
}
A stronger design should also enforce minimum deployed liquidity and minimum capital utilization. Checking only for a non-zero result can still permit economically meaningless values near rounding boundaries.
3. Separate asset accounting from strategy state
Withdrawal rights must be derived from assets and shares, not from whether an LP position happens to exist:
userAssets = totalManagedAssets * userShares / totalShares
totalManagedAssets should account for at least:
- DAI and USDC held in the Uniswap V4 position;
- idle DAI and USDC held by the vault;
- collected assets waiting to be reinvested;
- any reliably measurable principal or fees pending collection.
The strategy layer decides where capital is deployed. The accounting layer decides who owns it. A single liquidity variable cannot safely answer both questions.
4. Harden reposition() against price manipulation
Checking only the current tick is not sufficient for a publicly callable vault rebalance. Practical defenses include:
- validating the price against a TWAP or trusted oracle;
- enforcing a minimum post-reposition asset value;
- requiring
minLiquidityOutorminAssetsDeployed; - limiting single-block price movement;
- restricting execution to approved keepers or using delayed execution;
- testing zero-liquidity, one-sided balance, and rounding-boundary cases explicitly.
Panda Security’s vault audit checklist
For vaults connected to LP, lending, or compounding strategies, we recommend testing the following invariants:
1. A user can never receive more assets than their proportional ownership.
2. A partial redemption can never withdraw the vault's entire balance.
3. Failed strategy migration cannot alter asset ownership while shares exist.
4. liquidity == 0 does not imply totalAssets == 0.
5. Asset value before and after repositioning stays within an explicit limit.
6. Minimal share amounts cannot bypass proportional accounting.
7. An externally controlled price cannot select an unsafe payout branch.
Happy-path tests covering “deposit, earn fees, withdraw” are not enough. The most valuable cases often combine unusual states:
- a one-wei deposit followed by a one-wei redemption;
- a position moving completely out of range;
- removal of an old position followed by a zero new-liquidity result;
- token balances remaining while LP liquidity is zero;
- withdrawals by the final shareholder and by a non-final shareholder;
- price manipulation combined with permissionless keeper actions.
Final thoughts
The attacker did not break Uniswap and did not need a sophisticated reentrancy chain. They moved the vault into a state where asset ownership had never been properly defined, then called a public withdrawal function.
liquidity == 0 is a strategy condition, not proof of ownership. As long as multiple shares exist, every withdrawal path must return to the same basic rule: the user’s payout is determined by their ownership percentage, regardless of whether the assets are currently inside an LP position or sitting idle in the vault.
That distinction is central to Panda Security’s approach to vault reviews: verify asset conservation and share invariants before reviewing yield logic. A yield bug reduces returns; an ownership bug empties the vault.
Vaults, AMM strategies, auto-compounding systems, and Uniswap V4 integrations should undergo source review, state-machine testing, and mainnet-fork attack simulation before deployment. Panda Security will continue turning real-world exploit paths into concrete audit checks that teams can apply before attackers do.
Top comments (0)