Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Crazy Cyan Worm - PodUnwrapLocker will cause financial loss for users as debond fees are applied during lock creation #556

Open
sherlock-admin3 opened this issue Feb 17, 2025 · 0 comments

Comments

@sherlock-admin3
Copy link
Contributor

Crazy Cyan Worm

High

PodUnwrapLocker will cause financial loss for users as debond fees are applied during lock creation

Summary

The protocol's design intended to allow fee-free debonding via cooldown locking through PodUnwrapLocker, but incorrectly uses the fee-applying WeightedIndex.debond path. This results in:

  1. Hidden Fee Deduction: Debond fees are applied during lock creation, reducing stored amounts before cooldown
  2. Normal Withdrawal Loss: Users receive less value than deposited despite waiting full lock period
  3. Compounded Early Penalty: Early withdrawal penalties calculate on pre-reduced amounts, amplifying losses
    The root cause stems from missing a fee-exempt debond path for locker-initiated transactions, violating core protocol guarantees about cooldown withdrawals.

Root Cause

In function PodUnwrapLocker.debondAndLock (PodUnwrapLocker.sol#L76) the locker contract calls WeightedIndex.debond which applies debond fees (WeightedIndex.sol#L176-L178), contradicting the locker's design purpose of fee-free debonding:

		/**
		 * @title PodUnwrapLocker
@>	 * @notice Allows users to debond from pods fee free with a time-lock period before they can withdraw their tokens.
		 * The lock duration is determined by each pod's debondCooldown config setting. Users can withdraw early if they choose,
		 * however they will realize a debondFee + 10% fee on early withdraw.
		 */
		contract PodUnwrapLocker is Context, ReentrancyGuard {
				// ...
		
		    function debondAndLock(address _pod, uint256 _amount) external nonReentrant {
						// ...
		
		        // Get token addresses and balances before debonding
		        for (uint256 i = 0; i < _tokens.length; i++) {
		            _tokens[i] = _podTokens[i].token;
		            _balancesBefore[i] = IERC20(_tokens[i]).balanceOf(address(this));
		        }
@>	        _podContract.debond(_amount, new address[](0), new uint8[](0));
		
		        uint256[] memory _receivedAmounts = new uint256[](_tokens.length);
		        for (uint256 i = 0; i < _tokens.length; i++) {
@>	            _receivedAmounts[i] = IERC20(_tokens[i]).balanceOf(address(this)) - _balancesBefore[i];
		        }
		
						// ...
						
		        locks[_lockId] = LockInfo({
		            user: _msgSender(),
		            pod: _pod,
		            tokens: _tokens,
@>	            amounts: _receivedAmounts,
		            unlockTime: block.timestamp + _podConfig.debondCooldown,
		            withdrawn: false
		        });
		        
		        // ...						
		    }
		}
		contract WeightedIndex is Initializable, IInitializeSelector, DecentralizedIndex {
				// ...
		
		    function debond(uint256 _amount, address[] memory, uint8[] memory) external override lock noSwapOrFee {
		        uint256 _amountAfterFee = _isLastOut(_amount) || REWARDS_WHITELIST.isWhitelistedFromDebondFee(_msgSender())
		            ? _amount
@>	            : (_amount * (DEN - _fees.debond)) / DEN;
@>	        uint256 _percSharesX96 = (_amountAfterFee * FixedPoint96.Q96) / _totalSupply;

						// ...
						
		        for (uint256 _i; _i < _il; _i++) {
@>	            uint256 _debondAmount = (_totalAssets[indexTokens[_i].token] * _percSharesX96) / FixedPoint96.Q96;
		            if (_debondAmount > 0) {
		                _totalAssets[indexTokens[_i].token] -= _debondAmount;
@>	                IERC20(indexTokens[_i].token).safeTransfer(_msgSender(), _debondAmount);
		            }
		        }
						
						// ...
		    }
		    
		    // ...
	 }

This results in:

  1. Locked amounts being reduced by debond fees before storage

  2. Normal withdrawal receiving reduced amount due to charged debond fees (PodUnwrapLocker.withdraw):

    function withdraw(uint256 _lockId) external nonReentrant {
        _withdraw(_msgSender(), _lockId);
    }
    
    function _withdraw(address _user, uint256 _lockId) internal {
				// ...

        for (uint256 i = 0; i < _lock.tokens.length; i++) {
            if (_lock.amounts[i] > 0) {
@>              IERC20(_lock.tokens[i]).safeTransfer(_user, _lock.amounts[i]);
            }
        }
				
				// ...
    }    
  1. Early withdrawals applying penalty fees on already reduced amounts (PodUnwrapLocker.earlyWithdraw):
    function earlyWithdraw(uint256 _lockId) external nonReentrant {
				// ...

        // Penalty = debond fee + 10%
@>      uint256 _penaltyBps = _debondFee + _debondFee / 10;
        uint256[] memory _penalizedAmounts = new uint256[](_lock.tokens.length);
        
        for (uint256 i = 0; i < _lock.tokens.length; i++) {
            if (_lock.amounts[i] > 0) {
@>              uint256 _penaltyAmount = (_lock.amounts[i] * _penaltyBps) / 10000;
                _penaltyAmount = _penaltyAmount == 0 && _debondFee > 0 ? 1 : _penaltyAmount;
@>              _penalizedAmounts[i] = _lock.amounts[i] - _penaltyAmount;
                if (_penaltyAmount > 0) {
                    IERC20(_lock.tokens[i]).safeTransfer(_feeRecipient, _penaltyAmount);
                }
@>              IERC20(_lock.tokens[i]).safeTransfer(_msgSender(), _penalizedAmounts[i]);
            }
        }
				
				// ...
    }

The core issue stems from using the standard debond path that applies fees, rather than implementing a dedicated fee-exempt debond mechanism for locker-initiated transactions. This violates the architectural intent of providing a fee-free cooldown withdrawal option.

Internal Pre-conditions

  1. PodUnwrapLocker uses standard debond mechanism that applies 5% debond fee (example value)
  2. Locked token amounts are stored post-fee deduction
  3. Early withdrawal penalty (5.5% in example, 5% + 5% / 10) calculated on reduced amounts

External Pre-conditions

  1. User initiates debond-lock with 1000 POD tokens (1:1 asset backing in example)
  2. User expects full 1000 token value after lock period
  3. Protocol advertises locker as "fee-free with cooldown"

Attack Path

  1. User calls PodUnwrapLocker.debondAndLock(1000 POD)
    • Internally calls WeightedIndex.debond where applying 5% debond fees
    • PodUnwrapLocker contract receives 950 underlying tokens (1:1 example rate, 5% fee applied)
    • 950 tokens stored in locker
  2. Normal Withdraw Scenario:
    • User calls PodUnwrapLocker.withdraw after required lock period
    • User expects full withdrawal (1000 tokens)
    • Receives stored lock-amount 950 tokens (5% loss vs expected 1000 tokens)
  3. Early Withdraw Scenario:
    • User calls PodUnwrapLocker.earlyWithdraw
      • User expects withdrawal with penalty: 1000 - (1000 * 5.5%) = 945 tokens
    • Penalty applied to stored lock amount: 950 * 5.5% = 52.25 tokens
    • User receives 897.75 tokens (5% total loss vs expected 945 tokens)

Impact

  1. Direct Financial Loss:
    • Normal withdrawal: 5% loss (50 tokens on 1000 POD)
    • Early withdrawal: 5% loss (50 tokens on 1000 POD)
  2. Protocol Functionality Failure:
    • Core feature (fee-free cooldown) operates with hidden fees
    • Early withdrawal penalty exceeds documented rates

PoC

No response

Mitigation

  1. Implement Fee-Exempt Debond Path:

    • Create debondNoFee function in WeightedIndex contract
    • Restrict access to PodUnwrapLocker via modifier
    • Bypass fee calculation when called by locker
  2. Modify Locker Workflow:

    • Replace debond call with debondNoFee in PodUnwrapLocker.debondAndLock
    • Store pre-fee token amounts in locker
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant