How One Wei of Shares Drained 14,838 DAI: A Uniswap V4 Vault Exploit | PandaSecurity
Web3 Security

How One Wei of Shares Drained 14,838 DAI: A Uniswap V4 Vault Exploit

PandaSecurity Team •
#DeFi Security#Smart Contract Audit#Vault Exploit
Panda Security analysis of a zero-liquidity DeFi 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

ItemDetails
NetworkPolygon PoS
Exploit timeOctober 9, 2026, 02:23:42 UTC
Attacker EOA0x59Ae3c8d...B64e9884B
Affected vault0x048E8831...e2AF2e5A0
Exploit contract0xc2C6F4F7E1425bE1DCB996175Cf7852bF53c23E2
Key transaction0x5b795dc5...e04a7ff2
Amount sent by the vault14,838.466608989135865711 DAI
Final transfer to attacker EOAApproximately 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:

  1. reposition() can remove all liquidity from the old position.
  2. The new-liquidity calculation can return zero.
  3. The withdrawn assets remain at the vault address.
  4. Existing user shares remain valid.
  5. _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:

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:

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:

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:

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.

Related articles

← Back to Blog