Skip to content
New issue

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

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

Already on GitHub? # 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 de#tended 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
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant