<!-- canonical: https://0xsimao.com/findings/yieldoor-locked-funds-underflow-withdrawal -->

# Locked funds due to underflow in withdrawal

Medium · Sherlock · CLMM · 24th February 2025

Finding M-9 of the Yieldoor competition.

- Protocol: https://audits.sherlock.xyz/contests/791
- Report: /reports/yieldoor
- Codebase: https://github.com/0xsimao/2025-02-yieldoor/tree/b5a0f779dce4236b02665606adb610099451a51a
- Source: https://github.com/sherlock-audit/2025-02-yieldoor-judging/issues/407

---

### Summary



In the withdraw function of the Leverager contract, when the borrowed asset is token1, the code uses the wrong variable in its conditional subtraction. Instead of using the available amount of token1 (amountOut1), it mistakenly uses amountOut0 (the token0 balance) to determine how much to subtract from the owed amount. This error causes an arithmetic underflow if amountOut1 (the correct balance) is less than the owed amount, because the ternary operator selects amountOut0 even though it might be much larger. In Solidity 0.8.x, arithmetic underflow reverts the transaction, blocking the withdrawal.








### Root Cause

The bug is a simple copy‑paste error. In the branch where `borrowed == up.token1`, the code should reference `amountOut1` in the ternary operator. Instead, it mistakenly uses `amountOut0`, leading to an underflow when `token1's balance is insufficient.`

### Internal Pre-conditions

None. 

### External Pre-conditions

None. 

### Attack Path

https://github.com/sherlock-audit/2025-02-yieldoor/blob/main/yieldoor/src/Leverager.sol#L209

In the withdraw function, after obtaining withdrawal amounts from the vault:
```solidity 
uint256 bIndex = ILendingPool(lendingPool).getCurrentBorrowingIndex(borrowed);
uint256 totalOwedAmount = up.borrowedAmount * bIndex / up.borrowedIndex;
uint256 owedAmount = totalOwedAmount * wp.pctWithdraw / 1e18;
// ...
if (borrowed == up.token0) {
    uint256 repayFromWithdraw = amountOut0 < owedAmount ? amountOut0 : owedAmount;
    owedAmount -= repayFromWithdraw;
    amountOut0 -= repayFromWithdraw;
} else if (borrowed == up.token1) {
    // BUG: Should use amountOut1 below instead of amountOut0.
    uint256 repayFromWithdraw = amountOut1 < owedAmount ? amountOut0 : owedAmount;
    owedAmount -= repayFromWithdraw;
    amountOut1 -= repayFromWithdraw;
}
```

2. Issue Trigger:

When borrowed equals up.token1, the condition if (amountOut1 < owedAmount) is met.

Instead of using `amountOut1` (the correct available balance of token1), the code mistakenly uses `amountOut0` to compute repayFromWithdraw.

For example, assume:

`owedAmount` = 150

`amountOut1` = 100 (available token1 is less than owed)

`amountOut0` = 300 (available token0 is much higher)


The ternary operator incorrectly selects amountOut0 (300) because amountOut1 < owedAmount is true.

Then, the code performs:

`owedAmount -= repayFromWithdraw; // 150 - 300`, which underflows.

Solidity 0.8 enforces checked arithmetic, so this underflow causes a revert.



3. Consequences:

Denial of Withdrawal: Users with positions borrowing token1 cannot withdraw if the token1 liquidity is insufficient compared to token0's balance.

Funds Locked: Repeated failures due to underflow may lock user funds, preventing any withdrawal until the bug is fixed.

Attack Vector: Although likely unintentional, an attacker could manipulate the withdrawal scenario (e.g., by altering input amounts) to trigger the bug repeatedly, effectively causing a denial-of-service on withdrawals for token1 borrowers.

 



### Impact

Denial of Withdrawal: Users with positions borrowing token1 cannot withdraw if the token1 liquidity is insufficient compared to token0's balance.

Funds Locked: Repeated failures due to underflow may lock user funds, preventing any withdrawal until the bug is fixed.

### PoC

Foundry test demonstrating that the withdrawal call reverts due to the arithmetic underflow when the borrowed asset is token1:
```solidity 
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";

// Minimal interfaces and contract fragments required for the test.
interface IVault {
    function withdraw(uint256 shares, uint256 min0, uint256 min1) external returns (uint256, uint256);
}

interface ILendingPool {
    function getCurrentBorrowingIndex(address asset) external view returns (uint256);
    function repay(address asset, uint256 amount) external;
}

interface IERC20 {
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
}

contract Leverager {
    // Simplified Position struct used in the withdraw function.
    struct Position {
        address token0;
        address token1;
        address vault;
        uint256 borrowedAmount;
        uint256 borrowedIndex;
        address denomination; // borrowed token
        uint256 shares;
    }
    mapping(uint256 => Position) public positions;
    address public lendingPool;

    // Vulnerable withdraw function fragment.
    function withdraw(uint256 wp_pctWithdraw, uint256 shares, uint256 totalSupply, address msgSender) external {
        Position memory up = positions[1]; // using position ID 1 for testing
        // For testing, assume borrowed token equals up.token1.
        address borrowed = up.denomination;
        
        uint256 bIndex = ILendingPool(lendingPool).getCurrentBorrowingIndex(borrowed);
        uint256 totalOwedAmount = up.borrowedAmount * bIndex / up.borrowedIndex;
        uint256 owedAmount = totalOwedAmount * wp_pctWithdraw / 1e18;
        
        // Simulated values from vault.withdraw.
        // In our test, we set:
        // amountOut0 (token0 balance from vault) = 300
        // amountOut1 (token1 balance from vault) = 100, which is less than owedAmount = 150.
        uint256 amountOut0 = 300;
        uint256 amountOut1 = 100;
        
        if (borrowed == up.token0) {
            uint256 repayFromWithdraw = amountOut0 < owedAmount ? amountOut0 : owedAmount;
            owedAmount -= repayFromWithdraw;
            amountOut0 -= repayFromWithdraw;
        } else if (borrowed == up.token1) {
            // BUG: incorrectly uses amountOut0 instead of amountOut1.
            uint256 repayFromWithdraw = amountOut1 < owedAmount ? amountOut0 : owedAmount;
            // This line causes underflow: 150 - 300 underflows.
            owedAmount -= repayFromWithdraw;
            amountOut1 -= repayFromWithdraw;
        }
        
        // For testing, we simply require that owedAmount must be zero at end.
        require(owedAmount == 0, "Underflow detected: insufficient token1 funds");
    }
    
    // For testing: allow setting a position directly.
    function testSetPosition(Position calldata pos) external {
        positions[1] = pos;
    }
}

contract LeveragerWithdrawTest is Test {
    Leverager leverager;
    
    // Dummy lending pool to return a fixed borrowing index.
    contract DummyLendingPool {
        function getCurrentBorrowingIndex(address) external pure returns (uint256) {
            return 1e27;
        }
    }
    DummyLendingPool dummyLendingPool;
    
    function setUp() public {
        leverager = new Leverager();
        dummyLendingPool = new DummyLendingPool();
        // Set the dummy lending pool.
        // (In practice, this would be set via constructor or a setter.)
        // Here we cheat by writing directly to the storage slot for demonstration.
        // For our test, assume lendingPool is address(dummyLendingPool).
        (bool success, ) = address(leverager).call(abi.encodeWithSignature("setLendingPool(address)", address(dummyLendingPool)));
        // If the above fails (due to simplified contract), we assume lendingPool is dummyLendingPool.
        
        // Setup a position where:
        // - borrowedAmount = 150, borrowedIndex = 1e27, so totalOwedAmount = 150.
        // - Borrowed token (denomination) is token1.
        Leverager.Position memory pos = Leverager.Position({
            token0: address(0x1),
            token1: address(0x2),
            vault: address(0x3),
            borrowedAmount: 150,
            borrowedIndex: 1e27,
            denomination: address(0x2), // token1
            shares: 1000
        });
        leverager.testSetPosition(pos);
    }
    
    function testWithdrawRevertsDueToBug() public {
        // Expect the withdrawal to revert due to underflow caused by the wrong variable reference.
        vm.expectRevert();
        // Call withdraw with 100% withdrawal (pctWithdraw = 1e18) and dummy shares/totalSupply.
        leverager.withdraw(1e18, 1000, 1000, address(this));
    }
}
```

### Mitigation

Correct the code by replacing:

uint256 repayFromWithdraw = amountOut1 < owedAmount ? amountOut0 : owedAmount;

with

uint256 repayFromWithdraw = amountOut1 < owedAmount ? amountOut1 : owedAmount;

---

Related findings:

- [Providing liquidity to the AMM does not check the return value of actually provided tokens leading to locked funds.](https://0xsimao.com/findings/cork-protocol-providing-actually-provided-locked): Cork Protocol
- [Users redeeming early will withdraw `Ra` without decreasing the amount locked, which will lead to stolen funds when withdrawing after expiry](https://0xsimao.com/findings/cork-protocol-redeeming-withdraw-locked-stolen): Cork Protocol
- [`FluidLocker::_getUnlockingPercentage()` uses 540 instead of `540 days` leading to stuck funds as the unlocking percentage will be bigger than `100%` and underflow](https://0xsimao.com/findings/superfluid-locker-system-540-stuck-bigger-underflow): Superfluid Locker System
- [Users will steal excess funds from the Vault due to `VaultPoolLib::redeem()` not always decreasing `self.withdrawalPool.raBalance` and `self.withdrawalPool.paBalance`](https://0xsimao.com/findings/cork-protocol-steal-excess-redeem-withdrawal): Cork Protocol
