Skip to content

Instantly share code, notes, and snippets.

@gjaldon
Last active May 31, 2024 03:42
Show Gist options
  • Save gjaldon/f3d1e2410f6e52370c8f19e72b98ea5c to your computer and use it in GitHub Desktop.
Save gjaldon/f3d1e2410f6e52370c8f19e72b98ea5c to your computer and use it in GitHub Desktop.
Audit Report for Popcorn

[H-01] Withdrawals frequently revert due to restrictive slippage protection

When redeeming from the Pendle Adapter, liquidity is removed from the Pendle Router in exchange for the base asset (USDe or wstETH.)

function _protocolWithdraw(
    uint256 amount,
    uint256
) internal virtual override {
    address asset = asset();
    uint256 protocolAmount = amount - IERC20(asset).balanceOf(address(this));

    // Empty structs
    LimitOrderData memory limitOrderData;
    SwapData memory swapData;

    TokenOutput memory tokenOutput = TokenOutput(
        asset,
        protocolAmount, // @audit-issue overly restrictive slippage tolerance breaks redemption/withdrawals
        asset,
        address(0),
        swapData
    );

    pendleRouter.removeLiquiditySingleToken(
        address(this),
        pendleMarket,
        amountToLp(amount, totalAssets()),
        tokenOutput,
        limitOrderData
    );
}

The 2nd field for TokenOutput is the minTokenOut and sets the expected amount received from Pendle. This serves as slippage protection but it is overly restrictive in this case. It will revert with precision loss of just 1.

An attacker can also decrease the assets returned by Pendle by decreasing the Pendle Market's totalSy prior to a user's withdrawal from the Adapter. This breaks withdrawal for the user.

Recommendation: Consider setting the slippage protection in some way. This needs to be a static value that can not be manipulated via MEV. Typically, this value is set by the function caller as the minimum amount they expect to receive.

[H-02] Withdrawals frequently break due to precision loss

The Pendle Adapter inherits from the Adapter Base which implements some of the withdraw logic.

function _withdraw(
    address caller,
    address receiver,
    address owner,
    uint256 assets,
    uint256 shares
) internal virtual override {
    if (shares == 0 || assets == 0) revert ZeroAmount();

    if (caller != owner) {
        _spendAllowance(owner, caller, shares);
    }

    if (!paused()) {
        _protocolWithdraw(assets, shares);
    }

    _burn(owner, shares);

    // @audit-issue this fails when assets withdrawn via _protocolWithdraw are less
    IERC20(asset()).safeTransfer(receiver, assets);

    if (autoHarvest) harvest();

    emit Withdraw(caller, receiver, owner, assets, shares);
}

When the Adapter holds no balance of the base asset, _protocolWithdraw() is expected to withdraw at least the exact amount of assets or else the withdraw would revert. In the case of Pendle, withdrawing from the protocol could be removing liquidity tokens from a Market. The amount of assets withdrawn for removing liquidity is based on the totalSy and totalSupply of the market which can be manipulated.

An attacker can front-run the withdrawal by removing SY tokens from the Pendle Market to decrease the amount of assets withdrawn by the user by at least 1. When the user receives 1 less asset, the transfer of assets will fail due to lack of balance.

Recommendation: Consider overriding withdraw and replace its transfer logic to transfer only the amount of assets received from the protocol withdrawal. Since the amount can be less or more, the transfer amount can be computed by subtracting the balance prior to the protocol withdrawal from the balance after the protocol withdrawal.

[H-03] Unpausing is prone to sandwich attacks

Pauses withdraw the Adapter's whole balance from the Pendle Market and unpauses deposit its whole asset balance into Pendle.

ref: https://github.com/Popcorn-Limited/contracts/blob/feat/pendle-adapter/src/vault/adapter/abstracts/AdapterBase.sol#L431-L435

function unpause() external onlyOwner {
    // @audit-issue this can be frontrun. greater losses in these cases since larger amounts are deposited
    _protocolDeposit(totalAssets(), totalSupply());
    _unpause();
}

No slippage protection is applied when the Adapter deposits into Pendle. This leaves the large deposit open to sandwich attacks that can steal a large chunk of the Adapter's assets.

ref: https://github.com/Popcorn-Limited/contracts/blob/feat/pendle-adapter/src/vault/adapter/pendle/PendleAdapter.sol#L196-L203

function _protocolDeposit(
    uint256 amount,
    uint256
) internal virtual override {
    // ... snip ...
    pendleRouter.addLiquiditySingleToken(
        address(this),
        pendleMarket,
        0, // @audit-issue no slippage protection
        approxParams,
        tokenInput,
        limitOrderData
    );
}

Recommendation: Consider implementing slippage protection somehow where the user is able to set the minimum amount of LP tokens it receives from Pendle.

[M-01] Setting harvest data can only be run once

Every time harvest data is set, a token's reward data is pushed on to the rewardTokensData array.

ref: https://github.com/Popcorn-Limited/contracts/blob/f13c63c6e77a593b246c917fa288faa39f938eeb/src/vault/adapter/pendle/PendleAdapterBalancerCurveHarvest.sol#L73-L76

for (uint256 i = 0; i < len; i++) {
    rewardTokensData.push(rewData[i]);
    _approveSwapTokens(rewData[i].pathAddresses, _balancerRouter);
}

When setting harvest data is run more than once, the rewardTokensData will have repeated elements. In the case of adding a new reward token, the owner will need to set harvest data again and include the new reward token. However, this will lead to harvest() being unable to claim the new reward token because the only part of the rewardTokensData will be accessed.

function harvest() public override takeFees {
    // ... snip ...
    address[] memory rewTokens = _getRewardTokens();
    uint256 rewLen = rewTokens.length;

    for (uint256 i = 0; i < rewLen; i++) {
        address rewardToken = rewTokens[i];
        amount = IERC20(rewardToken).balanceOf(address(this));

        BalancerRewardTokenData memory rewData = rewardTokensData[i];
        if (amount > rewData.minTradeAmount) {
            // perform all the balancer single swaps for this reward token
            // ... snip ...
        }
    }
}

Note that only the rewardTokensData within the length of rewTokens will be accessed and rewTokens will have repeated elements and will be larger than rewTokens after the second call.

Some SY tokens, like SY tokens for Balancer LPs, support adding reward tokens.

Recommendation: Consider using a mapping for rewardTokensData instead of an array.

[M-02] Incorrect conversion of asset amount to LP token amount

When assets are withdrawn from the Pendle Adapter, the Adapter converts the asset amount so it knows the amount of LP tokens to withdraw from the Pendle Market. In the most common case, it does the conversion with the following:

lpBalance * assetAmount / totalAssets

lpBalance - The balance of LP tokens held by the Adapter
assetAmount - Amount of assets to be withdrawn

totalAssets = asset balance in this adapter + asset balance in LP

Th issue lies in totalAssets which includes the Adapter's asset balance. The correct computation would be for totalAssets to only include the asset balance deposited by the Adapter in the LP.

If 10% of total assets is the asset balance of the Adapter, then the conversion result will be considerably less than it should be. Withdrawals could revert because of this if there is not enough assets in the Adapter to satisfy the withdrawal. This becomes more of an issue when protocol deposits are delayed.

Recommendation: Consider fixing the conversion to LP amount by deducting the Adapter's asset balance from the totalAssets. Note that it may also be necessary to check that total assets is more than the asset balance before the subtraction.

    function amountToLp(
        uint256 amount,
        uint256 totAssets // totalAssets - asset balance + assets deposited in the Market
    ) internal view returns (uint256 lpAmount) {
        uint256 lpBalance = IERC20(pendleMarket).balanceOf(address(this));
+       totAssets -= asset().balanceOf(address(this)); 
        amount == totAssets 
            ? lpAmount = lpBalance
            : lpAmount = lpBalance.mulDiv(amount, totAssets, Math.Rounding.Ceil);
    }

[M-03] Curve and Balancer swaps when harvesting have no slippage protection

Both Pendle Harvest contracts do not implement any slippage protections when swapping their claimed reward tokens for the asset token.

// In `PendleAdapterBalancerCurveHarvest::harvest()`
curveRouter.exchange(curveSwap.route, curveSwap.swapParams, amount, 0, curveSwap.pools);
// In `PendleAdapterBalancerCurveHarvest::_singleBalancerSwap()` and `PendleAdapterBalancerHarvest::_singleBalancerSwap()`
amountOut = balancerRouter.swap(
    swap,
    fundManagement,
    0,  // @audit-issue no slippage protection
    block.timestamp + swapDelay
);

This leaves harvesting open to sandwich attacks that cause maximum slippage.

Recommendation: Consider adding slippage protection for all swaps done by the Pendle Harvest contracts.

[M-04] Unpausing can break when the Adapter's asset balance is more than the Pendle Market's supply cap

Some Pendle Markets have supply caps that need to be considered. However, when the Adapter deposits assets into the Pendle Market, it does not check whether the market has enough supply cap. The Adapter will deposit the maxDeposit amount if the amount happens to be equal to maxDeposit and it will deposit the asset balance if it is not equal.

ref: PendleAdapter::_protocolDeposit()

uint256 netInput = amount == maxDeposit(address(this))
    ? amount
    : IERC20(asset).balanceOf(address(this));

This can break unpausing since unpausing will try to deposit the Adapter's total assets into Pendle. When the Adapter's total assets is more than the supply cap of the SY token, unpausing will fail indefinitely.

Recommendation: Consider using the minimum of the asset balance and maxDeposit as the deposit amount for Pendle. This ensures that deposit amount will not exceed the SY token's supply cap.

[M-05] Incorrect use of maxDeposit

When the Adapter deposits assets into Pendle, it compares the amount of assets to be deposited with maxDeposit.

uint256 netInput = amount == maxDeposit(address(this))
            ? amount
            : IERC20(asset).balanceOf(address(this));

However, maxDeposit is not a value denominated in the base asset. It is in SY token which would need to be converted to the base asset.

function maxDeposit(address who) public view override returns (uint256) {
    try ISYTokenV3(pendleSYToken).supplyCap() returns (uint256 supplyCap) {
        return supplyCap - ISYTokenV3(pendleSYToken).totalSupply();
    } catch {
        return super.maxDeposit(who);
    }
}

This can cause Adapter unpauses to revert indefinitely.

Recommendation: Consider converting the result of max deposit to the base asset by multiplying it with the exchange rate. The following pseudocode can serve as reference.

// remaining supplyCap * pendleSYToken.exchangeRate(10 ** pendleSYToken.decimals())
uint256 remCap = supplyCap - ISYTokenV3(pendleSYToken).totalSupply();
return remCap * pendleSYToken.exchangeRate(10 ** pendleSYToken.decimals()) / exchangeRateDecimals;

[L-01] Token approvals will fail for tokens that do not have return values for their ERC20 functions

There are token approvals done in PendleAdapterBalancerCurveHarvest, PendleAdapter, and PendleAdapterBalancerHarvest. All of which use the IERC20 interface that expects approve() to return a boolean. However, not all tokens have return values for their ERC20 functions. The most prominent example of this is USDT.

Recommendation: Consider using OpenZeppelin's SafeERC20.forceApprove() which supports ERC20 tokens with no return values for their ERC20 functions and with approval race protections.

[L-02] Pendle withdrawals can fail when amount withdrawn is less than asset balance

Withdrawals from the Pendle Adapter triggers withdrawals from Pendle. The amount withdrawn, however, can not be more than the Adapter's asset balance.

ref: https://github.com/Popcorn-Limited/contracts/blob/feat/pendle-adapter/src/vault/adapter/pendle/PendleAdapter.sol#L213

function _protocolWithdraw(
        uint256 amount,
        uint256
    ) internal virtual override {
    address asset = asset();

    uint256 protocolAmount = amount - IERC20(asset).balanceOf(address(this));

This scenario is more likely when asset balances are not automatically deposited on every deposit or when the strategy withdraws assets with strategyWithdraw(). In both cases, the Adapter will hold a greater asset balance.

Recommendation: Consider checking that the withdrawal amount is less than the asset balance and only withdraw from Pendle if the Adapter does not have enough assets to satisfy the withdrawal.

@gjaldon
Copy link
Author

gjaldon commented May 31, 2024

Issue Status Comment
H-01 Not fixed
H-02 Not fixed
H-03 Fixed Pausing and unpausing no longer do protocol withdraws and deposits, respectively. Keeper/owner can do push or pull of funds to manually withdraw or deposit all assets from and to the protocol
M-01 Fixed Fix is applied by deleting old arrays and adding the new reward tokens. there are no longer any checks done on the reward tokens in the Pendle Market.
M-02 Fixed Fixed as recommended (no longer subtract asset balance from total assets)
M-03 Not fixed minOut is 0 at BaseCurveCompound::sellRewardsViaCurve(). Consider encoding 3 different values in data. One would be the minOut for _protocolDeposit(), 2nd would be the minOut for Curve, and 3rd would be the limits array for Balancer. Regarding Balancer - the swap is GIVEN_IN and if I’m not mistaken, setting negative limits is the way to set minOut for the assets withdrawn from the Balancer Vault. The limits set in the tests all use really large positive values. - Having limits set via a state variable won’t help in protecting against slippage since the amounts traded can vary widely. May be worth considering the above recommended fix of passing Curve and Balancer minOuts via data in Harvest
M-04 Fixed Fixed indirectly by no longer depositing when unpausing and by allowing keeper/owner to pushFunds(). assets amount can be specified by the owner/keeper so that it is within the supply cap of the SY Token.
M-05 Fixed but New Issue 1e18 is hard-coded which assumes that all syTokens will have 18 decimals. Basing on the SY Token specs, decimals should reflect the accounting asset’s decimals.
L-01 Fixed with Recommendations No longer need to remove old allowances since forceApprove already sets allowance to 0 before setting the new allowance. The following lines can be removed. No need to reset to 0 here No need to remove old allowances here No need to reset to 0 here
L-02 Fixed Removed the asset balance subtraction from the amount being withdrawn in _protocolWithdraw()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment