Platform: Code4rena
Start Date: 05/07/2023
Pot Size: $390,000 USDC
Total HM: 136
Participants: 132
Period: about 1 month
Judge: LSDan
Total Solo HM: 56
Id: 261
League: ETH
Rank: 14/132
Findings: 17
Award: $4,905.87
🌟 Selected for report: 6
🚀 Solo Findings: 1
154.5795 USDC - $154.58
USDO has a flash mint limit to ensure that only a maximum amount of USDO can be loaned in a single transaction. This is important because if there was no limit on the amount of USDO minted then you would effectively accumulate a huge amount of instantaneous debt. Usually the flash loan limit is defined by the available collateral, but because this is a flash mint (not a loan) governance sets the maximum limit to a sensible value of 100k USDO.
However this flash limit can be easily exceeded to borrow an arbitrarily high amount of USDO (capped by the block gas limit for computation), which results in a potentially unreasonable amount of instantaneous debt.
This is a relatively simple vulnerability to show and to resolve. Let's have a look at the method:
function flashLoan( IERC3156FlashBorrower receiver, address token, uint256 amount, bytes calldata data ) external override notPaused returns (bool) { require(token == address(this), "USDO: token not valid"); require(maxFlashLoan(token) >= amount, "USDO: amount too big"); require(amount > 0, "USDO: amount not valid"); uint256 fee = flashFee(token, amount); _mint(address(receiver), amount); require( receiver.onFlashLoan(msg.sender, token, amount, fee, data) == FLASH_MINT_CALLBACK_SUCCESS, "USDO: failed" ); uint256 _allowance = allowance(address(receiver), address(this)); require(_allowance >= (amount + fee), "USDO: repay not approved"); _approve(address(receiver), address(this), _allowance - (amount + fee)); _burn(address(receiver), amount + fee); return true; }
The interesting part of the above code that I could craft a receiver onFlashLoan
method that calls flashLoan
again. Since there is no check for reentrancy I could repeatedly call flashLoan
via the onFlashLoan
callback with the maximum 100_000e18
amount to flash mint 100k USDO at a time. For example I could do this loop 10 times to flash mint 1m USDO. Provided I pay the fees for each of the 10 flash mints then this set of calls will exit successfully.
Manual review
The OpenZeppelin reentrancy code should be inherited and the nonReentrant
modifier should be added to the flashLoan
method.
Reentrancy
#0 - c4-pre-sort
2023-08-04T22:42:34Z
minhquanym marked the issue as duplicate of #1043
#1 - c4-judge
2023-09-18T15:07:57Z
dmvt marked the issue as satisfactory
🌟 Selected for report: plainshift
209.4254 USDC - $209.43
https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L608-L625 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L639-L650 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/Market.sol#L442-L461 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L576-L580 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L148-L149 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/Market.sol#L66-L68 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/Penrose.sol#L500-L537
For BigBang liquidations I believe the intended behaviour is that the protocol takes a 10% cut of the profit and the caller should receive the remaining 90%. However the actual behaviour is that the liquidator takes between 1 and 10%, the protocol gets 10% and the rest is stuck forever. So for example, if the liquidator reward was 10%, the protocol would receive 10% and the remaining 80% would get stuck in the BigBang yield box.
This significantly disincentivises users to liquidate insolvent users in BigBang because the reward is significantly lower than expected and it also results in the unintended "burning" of capital.
A liquidator can liquidate a user by calling liquidate
, which leads to the following call flow:
liquidate -> _closedLiquidation -> _liquidateUser
The interesting part of _liquidateUser
is the following set of lines:
uint256 balanceBefore = yieldBox.balanceOf(address(this), assetId); ISwapper.SwapData memory swapData = swapper.buildSwapData( collateralId, assetId, 0, collateralShare, true, true ); swapper.swap(swapData, minAssetMount, address(this), ""); uint256 balanceAfter = yieldBox.balanceOf(address(this), assetId); uint256 returnedShare = balanceAfter - balanceBefore; (uint256 feeShare, uint256 callerShare) = _extractLiquidationFees( returnedShare, borrowShare, callerReward );
In these lines the seized collateral from the user being liquidated is swapped for USDO and then the liquidation fees are calculated based on the profit from the liquidation:
function _extractLiquidationFees( uint256 returnedShare, uint256 borrowShare, uint256 callerReward ) private returns (uint256 feeShare, uint256 callerShare) { uint256 extraShare = returnedShare - borrowShare; feeShare = (extraShare * protocolFee) / FEE_PRECISION; // x% of profit goes to fee. callerShare = (extraShare * callerReward) / FEE_PRECISION; // y% of profit goes to caller. yieldBox.transfer(address(this), penrose.feeTo(), assetId, feeShare); yieldBox.transfer(address(this), msg.sender, assetId, callerShare); }
As you can see the protocol takes its 10% fee (protocolFee = 10000; // 10%
) and then the caller fee is calculated. However, rather than using the callerFee
variable (callerFee = 90000; // 90%
) to calculated the caller fee, the callerReward
variable is used which is calculated by an earlier call to _getCallerReward
. This method calculates the reward based on the borrowed amount with a value between:
uint256 public minLiquidatorReward = 1e3; //1% /// @notice max % a liquidator can receive in rewards uint256 public maxLiquidatorReward = 1e4; //10%
Therefore, the user is getting a maximum reward of 10%. So, what happens to this remaining 80% - 89%? This stays stuck in the yield box of the BigBang contract. The only time the USDO balance of the BigBang yield box is touched is when fees are collected, however the fees are calculated dynamically in the Penrose _depositFeesToYieldBox
method, so the total balance of the yield box (accrued over liquidations) won't be able to be transferred to the fee receiver (i.e. the protocol).
function _depositFeesToYieldBox( IMarket market, ISwapper swapper, IPenrose.SwapData calldata dexData ) private { require(swappers[swapper], "Penrose: Invalid swapper"); require(isMarketRegistered[address(market)], "Penrose: Invalid market"); uint256 feeShares = market.refreshPenroseFees(feeTo); if (feeShares == 0) return; uint256 assetId = market.assetId(); uint256 amount = 0; if (assetId != wethAssetId) { yieldBox.transfer( address(this), address(swapper), assetId, feeShares ); ISwapper.SwapData memory swapData = swapper.buildSwapData( assetId, wethAssetId, 0, feeShares, true, true ); (amount, ) = swapper.swap( swapData, dexData.minAssetAmount, feeTo, "" ); } else { yieldBox.transfer(address(this), feeTo, assetId, feeShares); } emit LogYieldBoxFeesDeposit(feeShares, amount); }
As you can see the value transferred is feeShares
which is calculated based on the actual USDO balance of the BigBang contract (not its yield box USDO balance):
function refreshPenroseFees( address ) external onlyOwner notPaused returns (uint256 feeShares) { uint256 balance = asset.balanceOf(address(this)); totalFees += balance; feeShares = yieldBox.toShare(assetId, totalFees, false); if (totalFees > 0) { asset.approve(address(yieldBox), totalFees); yieldBox.depositAsset( assetId, address(this), msg.sender, totalFees, 0 ); totalFees = 0; } }
The funds intended for the liquidator are now stuck forever.
Manual review
The following changes should be made to the BigBang contract to send 90% of the liquidation profit to the liquidator as intended:
diff --git a/contracts/markets/bigBang/BigBang.sol b/contracts/markets/bigBang/BigBang.sol index c9b5397..f1c36c0 100644 --- a/contracts/markets/bigBang/BigBang.sol +++ b/contracts/markets/bigBang/BigBang.sol @@ -573,11 +573,7 @@ contract BigBang is BoringOwnable, Market { userCollateralShare[user], _exchangeRate ); - uint256 callerReward = _getCallerReward( - userBorrowPart[user], - startTVLInAsset, - maxTVLInAsset - ); + ( uint256 borrowAmount, @@ -621,8 +617,7 @@ contract BigBang is BoringOwnable, Market { uint256 returnedShare = balanceAfter - balanceBefore; (uint256 feeShare, uint256 callerShare) = _extractLiquidationFees( returnedShare, - borrowShare, - callerReward + borrowShare ); address[] memory _users = new address[](1); _users[0] = user; @@ -638,12 +633,11 @@ contract BigBang is BoringOwnable, Market { function _extractLiquidationFees( uint256 returnedShare, - uint256 borrowShare, - uint256 callerReward + uint256 borrowShare ) private returns (uint256 feeShare, uint256 callerShare) { uint256 extraShare = returnedShare - borrowShare; feeShare = (extraShare * protocolFee) / FEE_PRECISION; // x% of profit goes to fee. - callerShare = (extraShare * callerReward) / FEE_PRECISION; // y% of profit goes to caller. + callerShare = (extraShare * callerFee) / FEE_PRECISION; // y% of profit goes to caller. yieldBox.transfer(address(this), penrose.feeTo(), assetId, feeShare); yieldBox.transfer(address(this), msg.sender, assetId, callerShare);
Math
#0 - c4-pre-sort
2023-08-05T09:14:55Z
minhquanym marked the issue as duplicate of #1139
#1 - c4-judge
2023-09-13T09:14:41Z
dmvt changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-09-13T09:14:53Z
dmvt marked the issue as satisfactory
#3 - c4-judge
2023-10-10T21:26:01Z
dmvt changed the severity to 3 (High Risk)
#4 - c4-judge
2023-10-10T21:26:22Z
dmvt changed the severity to 2 (Med Risk)
🌟 Selected for report: carrotsmuggler
Also found by: Madalad, Ruhum, dirk_y, rvierdiiev, unsafesol
76.3356 USDC - $76.34
https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/lido/LidoEthStrategy.sol#L103-L112 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/lido/LidoEthStrategy.sol#L127-L138
Each of the YieldBox strategy contracts has an emergencyWithdraw
method that allows the owner of the strategy to withdraw all the assets that have been deployed in a yield generating position. This emergency withdraw functionality exists to allow the owner to protect deployed funds in the event there is an exploit or issue with the external protocols being interacted with. Withdrawing quickly could be the difference between saving most of the deployed user funds and losing most of the deployed funds.
However, there is currently nothing stopping a malicious user from instantly depositing the funds that were emergency withdrawn again. The malicious user can repeatedly redeploy these funds after the admin tries to withdraw them, completely defeating the point of the emergency withdraw functionality.
Luckily there is a workaround for the admin to set an arbitrarily high deposit threshold that I talk about more in the recommendation section. This is the reason for downgrading from High to Medium severity.
Each one of the YieldBox strategy contracts has an emergencyWithdraw
method that withdraws all the user funds that have been deposited to generate yield from the respective protocol. These are all slightly different depending on the underlying protocol it interacts with, however they all have the same core functionality. For the sake of argument we'll have a look at the LidoEthStrategy.sol
contract:
/// @notice withdraws everythig from the strategy function emergencyWithdraw() external onlyOwner returns (uint256 result) { compound(""); uint256 toWithdraw = stEth.balanceOf(address(this)); uint256 minAmount = (toWithdraw * 50) / 10_000; //0.5% result = curveStEthPool.exchange(1, 0, toWithdraw, minAmount); INative(address(wrappedNative)).deposit{value: result}(); }
As you can see, this method does what it's supposed to do and withdraws all the ETH that was deposited and wraps it to WETH.
In order to deploy these funds again, _deposited
has to be called again:
function _deposited(uint256 amount) internal override nonReentrant { uint256 queued = wrappedNative.balanceOf(address(this)); if (queued > depositThreshold) { require(!stEth.isStakingPaused(), "LidoStrategy: staking paused"); INative(address(wrappedNative)).withdraw(queued); stEth.submit{value: queued}(address(0)); //1:1 between eth<>stEth emit AmountDeposited(queued); return; } emit AmountQueued(amount); }
Since we've just withdrawn all the funds in an emergency, queued
is guaranteed to be larger that depositThreshold
. There is also no check here to prevent a call to _deposited
if an emergency withdrawal had just been performed. The YieldBox depositAsset
method also has no such checks, so all a malicious user has to do is call depositAsset
with a trivial amount of asset to trigger the deposit into the yield generating position again; the assets are now in a vulnerable position again.
Manual review
As I touched upon at the start of this report, a nice way to prevent a malicious user from instantly redepositing funds that have been withdrawn in an emergency is to set the deposit threshold to a huge number during the emergencyWithdraw
call. This can then be set back to a lower value at a later date by the owner if desired. Below is a suggested diff that should be applied to all the strategy contracts:
diff --git a/contracts/lido/LidoEthStrategy.sol b/contracts/lido/LidoEthStrategy.sol index f7aa0ea..ea4c191 100644 --- a/contracts/lido/LidoEthStrategy.sol +++ b/contracts/lido/LidoEthStrategy.sol @@ -109,6 +109,8 @@ contract LidoEthStrategy is BaseERC20Strategy, BoringOwnable, ReentrancyGuard { result = curveStEthPool.exchange(1, 0, toWithdraw, minAmount); INative(address(wrappedNative)).deposit{value: result}(); + + depositThreshold = type(uint256).max; } // ************************* //
Other
#0 - c4-pre-sort
2023-08-06T05:47:20Z
minhquanym marked the issue as duplicate of #1522
#1 - c4-judge
2023-09-18T19:51:47Z
dmvt marked the issue as satisfactory
76.3356 USDC - $76.34
https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/Balancer.sol#L200-L208 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/Balancer.sol#L280-L295 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/Balancer.sol#L322-L332
The mTapiocaOFT.sol
contract is a special TOFT implementation that can balance its supply. The supply of the token is balanced across chains by using Stargate. When balancing a mTapiocaOFT
through Stargate a cross chain swap fee has to be paid in native gas on the source chain as detailed in https://stargateprotocol.gitbook.io/stargate/developers/cross-chain-swap-fee.
However the current calls to Stargate will always fail because none of the native token value is passed along in the call. As a result the mTapiocaOFT
representation of a token will never be able to be balanced across chain.
The owner of the Balancer.sol
contract can perform a rebalance operation by calling rebalance
. The part of this method that is interesting for this report is part where different paths are taken depending on the type of token:
//send bool _isNative = ITapiocaOFT(_srcOft).erc20() == address(0); if (_isNative) { if (msg.value <= _amount) revert FeeAmountNotSet(); _sendNative(_srcOft, _amount, _dstChainId, _slippage); } else { if (msg.value == 0) revert FeeAmountNotSet(); _sendToken(_srcOft, _amount, _dstChainId, _slippage, _ercData); }
As you can see the mTapiocaOFTs that wrap a native token take a different path by calling _sendNative
. In _sendNative
the Stargate RouterETH
is called:
function _sendNative( address payable _oft, uint256 _amount, uint16 _dstChainId, uint256 _slippage ) private { if (address(this).balance < _amount) revert ExceedsBalance(); routerETH.swapETH( _dstChainId, _oft, //refund abi.encodePacked(connectedOFTs[_oft][_dstChainId].dstOft), _amount, _computeMinAmount(_amount, _slippage) ); }
whereas for non-native tokens _sendToken
is called. We'll consider just the _sendNative
case going forwards, but both have the same issue.
The bug in this code is that no native token (for the sake of argument let's say ETH) is forwarded with the external call to swapETH
. As you can see from the implementation of this method (also found at https://etherscan.io/address/0x150f94B44927F078737562f0fcF3C95c01Cc2376#code), the call will instantly revert as no ETH is forwarded:
function swapETH( uint16 _dstChainId, // destination Stargate chainId address payable _refundAddress, // refund additional messageFee to this address bytes calldata _toAddress, // the receiver of the destination ETH uint256 _amountLD, // the amount, in Local Decimals, to be swapped uint256 _minAmountLD // the minimum amount accepted out on destination ) external payable { require(msg.value > _amountLD, "Stargate: msg.value must be > _amountLD");
Manual review
The external calls to swapETH
and swap
should forward the appropriate amount of native token to pay for the cross chain swap fees:
diff --git a/contracts/Balancer.sol b/contracts/Balancer.sol index b58058d..84b8e26 100644 --- a/contracts/Balancer.sol +++ b/contracts/Balancer.sol @@ -285,7 +284,7 @@ contract Balancer is Owned { ) private { if (address(this).balance < _amount) revert ExceedsBalance(); - routerETH.swapETH( + routerETH.swapETH{value: address(this).balance}( _dstChainId, _oft, //refund abi.encodePacked(connectedOFTs[_oft][_dstChainId].dstOft), @@ -319,7 +318,7 @@ contract Balancer is Owned { }); erc20.approve(address(router), _amount); - router.swap( + router.swap{value: msg.value}( _dstChainId, _srcPoolId, _dstPoolId,
ETH-Transfer
#0 - c4-pre-sort
2023-08-05T16:47:15Z
minhquanym marked the issue as duplicate of #179
#1 - c4-pre-sort
2023-08-07T10:07:45Z
minhquanym marked the issue as duplicate of #813
#2 - c4-judge
2023-09-29T18:32:43Z
dmvt marked the issue as satisfactory
453.755 USDC - $453.76
https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/convex/ConvexTricryptoStrategy.sol#L189-L190 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/convex/ConvexTricryptoStrategy.sol#L148-L149 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/convex/ConvexTricryptoStrategy.sol#L332
For the YieldBox strategies, each strategy that has the concept of rewards has the ability to compound these rewards by redeeming, swapping, and then redepositing into the same yield generating strategy. Every strategy compounds during withdrawal and emergency withdrawal.
However, the ConvexTricryptoStrategy never actually compounds the rewards during either of these calls. This is bad for 3 main reasons:
compound
to compound the rewards. Yes, the user would be getting a share of the rewards, however this is unlikely to be worth the gas costs to call compound
on a relatively frequent cadence. As a result rewards will go un-compounded for a significant period of time (vs other strategies where rewards are compounded on most withdrawals)Like all the other YieldBox strategies, the ConvexTricryptoStrategy strategy has a compound
method. The interesting part of this method is the first line:
function compound(bytes memory data) public { if (data.length == 0) return;
As you can see, if 0 bytes are passed with the call, then the compounding action doesn't actually take place. Now, compound
is called in both _withdraw
and emergencyWithdraw
as designed, however for both of these calls, 0 bytes are passed:
/// @notice withdraws everythig from the strategy function emergencyWithdraw() external onlyOwner returns (uint256 result) { compound("");
/// @dev unstakes from Convex and withdraws from Curve function _withdraw( address to, uint256 amount ) internal override nonReentrant { uint256 available = _currentBalance(); require( available >= amount, "ConvexTricryptoStrategy: amount not valid" ); uint256 queued = wrappedNative.balanceOf(address(this)); if (amount > queued) { compound(bytes(""));
As a result compounding of rewards isn't happening when it's supposed to.
Manual review
The compound
calls should pass in the appropriate data to perform the compounding during withdrawals and emergency withdrawals. If this data can change it should be set to a variable in state through another onlyOwner
method as the reward tokens/pools change. Even if this pre-set data only covers 80% of the reward tokens/pools, this is a significant improvement. One-off calls can still be made to compound
to compound any rewards not captured on the regular cadence.
Other
#0 - c4-pre-sort
2023-08-06T12:35:28Z
minhquanym marked the issue as duplicate of #297
#1 - c4-judge
2023-09-29T23:18:27Z
dmvt marked the issue as selected for report
🌟 Selected for report: dirk_y
Also found by: carrotsmuggler
453.755 USDC - $453.76
https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/usd0/modules/USDOLeverageModule.sol#L62-L68 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/usd0/modules/USDOLeverageModule.sol#L190-L214 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L593
USDO and TapiocaOFT tokens can be sent across chains via LayerZero for leverage up and leverage down operations. This process is initiated through the sendForLeverage
method in both USDOLeverageModule.sol
and BaseTOFTLeverageModule.sol
. The caller that is performing the leverage operation is responsible for providing the relevant parameters that are required, including the contract address of the swapper contract on the destination chain.
However the address of the swapper contract isn't validated once the LayerZero message is received on the destination chain, therefore it is possible that the swapper contract isn't a whitelisted address. This means that the swapper contract could be malicious and would therefore be able to steal the funds (either TOFT or USDO) minted on the destination chain.
You might say that this is just user error, however this check is performed in other Tapioca contracts like liquidate
in BigBang.sol
, where the user is also able to pass in an arbitrary swapper address, but in these cases the swapper address is checked to be a whitelisted address. There is a non-zero chance that a user could be manipulated (either through a UI exploit or phishing) into performing a leverage operation with a malicious swapper contract. It would be extremely difficult to spot this at transaction signing time.
Let's consider a leverage up action that is initiated from the USDOLeverageModule.sol
:
function sendForLeverage( uint256 amount, address leverageFor, IUSDOBase.ILeverageLZData calldata lzData, IUSDOBase.ILeverageSwapData calldata swapData, IUSDOBase.ILeverageExternalContractsData calldata externalData ) external payable {
As you can see the user is able to provide arbitrary externalData
which includes the address for the destination chain swapper contract. The whitelist status of the contract can't be checked here, it needs to be checked on the destination chain.
The LayerZero message that is sent from the above call is received on the destination chain and leads to a call to leverageUp
and then leverageUpInternal
. The interesting part of this flow is the first part of leverageUpInternal
where the call to the swapper contract is made:
function leverageUpInternal( uint256 amount, IUSDOBase.ILeverageSwapData memory swapData, IUSDOBase.ILeverageExternalContractsData memory externalData, IUSDOBase.ILeverageLZData memory lzData, address leverageFor ) public payable { //swap from USDO _approve(address(this), externalData.swapper, amount); ISwapper.SwapData memory _swapperData = ISwapper(externalData.swapper) .buildSwapData( address(this), swapData.tokenOut, amount, 0, false, false ); (uint256 amountOut, ) = ISwapper(externalData.swapper).swap( _swapperData, swapData.amountOutMin, address(this), swapData.data );
As you can see, the swapper contract is approved to spend the balance minted earlier in the call, and there is no check as to whether the swapper contract is a whitelisted contract. As a result, the call to swap
could be used to steal the allowance and then return an amountOut
value of 0 (or a very small number) that then completes the rest of the execution.
As mentioned above, there are other flows in Tapioca that check the whitelist status of a swapper, including the liquidate flow:
require(penrose.swappers(swapper), "BigBang: Invalid swapper");
Given every supported chain needs to have whitelisted swappers anyway for other functionality, there is no good reason why leverage operations shouldn't also have the same protection applied to them to ensure users can't lose funds.
Manual review
The swapper contract should be checked on the destination chain during leverage operations. Below I have a suggested diff for USDO, but the same should also be applied to TOFT:
diff --git a/contracts/usd0/modules/USDOLeverageModule.sol b/contracts/usd0/modules/USDOLeverageModule.sol index 53dcdef..53dc9c8 100644 --- a/contracts/usd0/modules/USDOLeverageModule.sol +++ b/contracts/usd0/modules/USDOLeverageModule.sol @@ -195,6 +195,7 @@ contract USDOLeverageModule is BaseUSDOStorage { address leverageFor ) public payable { //swap from USDO + require(IPenrose(owner).swappers(externalData.swapper), "USDO: Invalid swapper"); _approve(address(this), externalData.swapper, amount); ISwapper.SwapData memory _swapperData = ISwapper(externalData.swapper) .buildSwapData(
Invalid Validation
#0 - c4-pre-sort
2023-08-06T12:29:30Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-21T14:11:50Z
0xRektora marked the issue as sponsor confirmed
#2 - c4-sponsor
2023-08-22T14:02:26Z
0xRektora marked the issue as sponsor disputed
#3 - c4-sponsor
2023-08-22T14:04:40Z
0xRektora marked the issue as sponsor acknowledged
#4 - c4-sponsor
2023-08-22T14:08:34Z
0xRektora marked the issue as sponsor confirmed
#5 - c4-judge
2023-09-27T20:47:25Z
dmvt marked the issue as selected for report
🌟 Selected for report: dirk_y
1008.3444 USDC - $1,008.34
The mTapiocaOFT.sol
contract is a special TOFT implementation that can balance its supply. The supply of the token is balanced across chains by using Stargate.
The issue with the current implementation is that when calling Stargate swap
to rebalance ERC20 tokens across chain there is an additional set of call parameters provided that are unnecessary and don't perform the function that is expected. Specifically the current implementation attempts to airdrop destination gas tokens to the destination OFT address even though this is completely unnecessary.
The relevant code can be found in _sendToken
in Balancer.sol
:
function _sendToken( address payable _oft, uint256 _amount, uint16 _dstChainId, uint256 _slippage, bytes memory _data ) private { IERC20Metadata erc20 = IERC20Metadata(ITapiocaOFT(_oft).erc20()); if (erc20.balanceOf(address(this)) < _amount) revert ExceedsBalance(); (uint256 _srcPoolId, uint256 _dstPoolId) = abi.decode( _data, (uint256, uint256) ); IStargateRouter.lzTxObj memory _lzTxParams = IStargateRouterBase .lzTxObj({ dstGasForCall: 0, dstNativeAmount: msg.value, dstNativeAddr: abi.encode( connectedOFTs[_oft][_dstChainId].dstOft ) }); erc20.approve(address(router), _amount); router.swap( _dstChainId, _srcPoolId, _dstPoolId, _oft, //refund, _amount, _computeMinAmount(_amount, _slippage), _lzTxParams, _lzTxParams.dstNativeAddr, "0x" ); }
Specifically we're looking at the _lzTxParams
creation:
IStargateRouter.lzTxObj memory _lzTxParams = IStargateRouterBase .lzTxObj({ dstGasForCall: 0, dstNativeAmount: msg.value, dstNativeAddr: abi.encode( connectedOFTs[_oft][_dstChainId].dstOft ) });
This isn't performing the function that I think the author is expecting. Here are some relevant snippets from the Stargate documentation:
// https://stargateprotocol.gitbook.io/stargate/developers/how-to-swap IStargateRouter.lzTxObj(0, 0, "0x") // 0 additional gasLimit increase, 0 airdrop, at 0x address // https://stargateprotocol.gitbook.io/stargate/v/user-docs/stargate-features/transfer#native-gas-delivery-transfer-to-destination-network // https://stargateprotocol.gitbook.io/stargate/developers/cross-chain-swap-fee ({ dstGasForCall: 0, // extra gas, if calling smart contract, dstNativeAmount: 0, // amount of dust dropped in destination wallet dstNativeAddr: taskArgs.dstNativeAddr // destination wallet for dust })
So what the author is actually doing here is airdropping msg.value
of the destination chain native gas token to the destination chain OFT address. There is no purpose to this action, and in fact it could result in the call to Stargate failing because you're trying to use some of the cross chain swap fee to airdrop gas tokens on the destination chain. Whether the call will fail depends on the swap fee provided and the difference in value between the source chain gas token and the destination chain gas token.
Manual review
The following change should be made to the existing code since there is no need to airdrop tokens on the destination chain:
diff --git a/contracts/Balancer.sol b/contracts/Balancer.sol index b58058d..d3151d2 100644 --- a/contracts/Balancer.sol +++ b/contracts/Balancer.sol @@ -312,10 +312,8 @@ contract Balancer is Owned { IStargateRouter.lzTxObj memory _lzTxParams = IStargateRouterBase .lzTxObj({ dstGasForCall: 0, - dstNativeAmount: msg.value, - dstNativeAddr: abi.encode( - connectedOFTs[_oft][_dstChainId].dstOft - ) + dstNativeAmount: 0, + dstNativeAddr: "0x0" }); erc20.approve(address(router), _amount);
Other
#0 - c4-pre-sort
2023-08-06T11:03:31Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-20T15:15:48Z
0xRektora marked the issue as sponsor disputed
#2 - c4-sponsor
2023-08-20T15:17:46Z
0xRektora marked the issue as sponsor confirmed
#3 - c4-judge
2023-09-29T23:20:40Z
dmvt marked the issue as selected for report
🌟 Selected for report: dirk_y
Also found by: 0x73696d616f, rvierdiiev
272.253 USDC - $272.25
https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/Balancer.sol#L197-L208 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/mTapiocaOFT.sol#L141-L146
The mTapiocaOFT.sol
contract is a special TOFT implementation that can balance its supply. The supply of the token is balanced across chains by using Stargate. When balancing a mTapiocaOFT
the caller needs to provide some native token to pay the Stargate fees.
The issue with the current implementation is that the fee check for mTapiocaOFTs
that represent native tokens is incorrect, forcing the admin to pay for the rebalance amount plus the fee.
A mTapiocaOFT
can be rebalanced if the owner of an approved balancer contract calls rebalance
in Balancer.sol
. As mentioned above, the rebalance operation uses Stargate to send tokens across chain. In order to pay the Stargate fees, some ETH (i.e. a native token) needs to be passes with any calls to Stargate and thus the rebalance
method needs to be payable (which it is). The issues comes where the value passed to the call is validated:
//extract ITapiocaOFT(_srcOft).extractUnderlying(_amount); //send bool _isNative = ITapiocaOFT(_srcOft).erc20() == address(0); if (_isNative) { if (msg.value <= _amount) revert FeeAmountNotSet(); _sendNative(_srcOft, _amount, _dstChainId, _slippage); } else { if (msg.value == 0) revert FeeAmountNotSet(); _sendToken(_srcOft, _amount, _dstChainId, _slippage, _ercData); }
As you can see, for native tokens the check is (msg.value <= _amount)
. However, we're already extracting the native token when calling extractUnderlying
:
function extractUnderlying(uint256 _amount) external { require(balancers[msg.sender], "TOFT_auth"); bool _isNative = erc20 == address(0); if (_isNative) { _safeTransferETH(msg.sender, _amount); } else { IERC20(erc20).safeTransfer(msg.sender, _amount); } emit Rebalancing(msg.sender, _amount, _isNative); }
So in order to rebalance amount x
across chain, the Balancer.sol
owner would have to call rebalance
with msg.value = x + fee
, and the actual amount being rebalanced would be 2 * x
.
However, as I've highlighted in another issue, the underlying call will luckily fail, but this is a distinct issue that should also be resolved.
Manual review
The fee check should be identical for both native and non native mTapiocaOFT
representations:
diff --git a/contracts/Balancer.sol b/contracts/Balancer.sol index b58058d..aa09a07 100644 --- a/contracts/Balancer.sol +++ b/contracts/Balancer.sol @@ -182,6 +182,7 @@ contract Balancer is Owned { onlyValidDestination(_srcOft, _dstChainId) onlyValidSlippage(_slippage) { + if (msg.value == 0) revert FeeAmountNotSet(); if (connectedOFTs[_srcOft][_dstChainId].rebalanceable < _amount) revert RebalanceAmountNotSet(); @@ -200,10 +201,8 @@ contract Balancer is Owned { //send bool _isNative = ITapiocaOFT(_srcOft).erc20() == address(0); if (_isNative) { - if (msg.value <= _amount) revert FeeAmountNotSet(); _sendNative(_srcOft, _amount, _dstChainId, _slippage); } else { - if (msg.value == 0) revert FeeAmountNotSet(); _sendToken(_srcOft, _amount, _dstChainId, _slippage, _ercData); }
Invalid Validation
#0 - c4-pre-sort
2023-08-05T16:43:10Z
minhquanym marked the issue as duplicate of #179
#1 - c4-pre-sort
2023-08-07T10:07:46Z
minhquanym marked the issue as duplicate of #813
#2 - c4-pre-sort
2023-08-07T10:12:48Z
minhquanym marked the issue as not a duplicate
#3 - c4-pre-sort
2023-08-07T10:16:02Z
minhquanym marked the issue as primary issue
#4 - c4-sponsor
2023-08-16T15:53:55Z
0xRektora marked the issue as disagree with severity
#5 - 0xRektora
2023-08-16T15:54:31Z
Should be medium
. Inconvenient for sure but user funds are not at risk.
#6 - c4-sponsor
2023-08-20T15:27:00Z
0xRektora marked the issue as sponsor confirmed
#7 - c4-judge
2023-09-21T12:22:31Z
dmvt changed the severity to 2 (Med Risk)
#8 - c4-judge
2023-09-21T12:25:27Z
dmvt marked the issue as selected for report
453.755 USDC - $453.76
https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionLiquidityProvision.sol#L171-L201 https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionLiquidityProvision.sol#L351-L354 https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionBroker.sol#L216-L220 https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionBroker.sol#L351-L359 https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionBroker.sol#L413-L417
Once a user has lent capital in a Singularity market, they can lock their tOLR tokens for a tOLP NFT. They can then use this tOLP to participate with the TapiocaOptionBroker
contract and mint an oTAP position. The minted oTAP gives the user the option to purchase TAP at a discount every epoch (week) for as long as the tOLR tokens are locked.
However a user can abuse the current system to only lock their tOLR tokens for 1 week, but be able to exercise 3 epochs worth of options to purchase TAP at a discount. The user is limiting their risk whilst maximising their rewards by circumventing the intended function of the option broker. Effectively a user is stealing funds from the protocol without interacting with the protocol as intended.
For the sake of argument, let's say that the TapiocaOptionBroker
epoch has just finished and is eligible to be incremented by calling newEpoch
, but hasn't been called yet.
A user that is trying to game the system could now bundle the following series of transactions:
lock
in TapiocaOptionLiquidityProvision.sol
with a lock duration equal to 1 week (1 epoch)participate
in TapiocaOptionBroker.sol
with the _tOLPTokenID
that was minted in the previous callexerciseOption
with the _oTAPTokenID
minted in the previous step to purchase TAP at a discountnewEpoch
to increment the epochexerciseOption
again to purchase TAP at a discount in this new epochAt this point the user has been able to exercise their option twice in the same block. Now the user can wait until the epoch is about to finish do the following:
newEpoch
to increment the epochexerciseOption
again to purchase TAP at a discount in this new epochThe user has now managed to exercise 3 weeks worth of options with just a single week lock. This is possible for a number of reasons:
function _isPositionActive(uint256 tokenId) internal view returns (bool) { LockPosition memory lockPosition = lockPositions[tokenId]; return (lockPosition.lockTime + lockPosition.lockDuration) >= block.timestamp; }
require( block.timestamp >= lastEpochUpdate + EPOCH_DURATION, "tOB: too soon" );
function lock( address _to, IERC20 _singularity, uint128 _lockDuration, uint128 _amount ) external returns (uint256 tokenId) { require(_lockDuration > 0, "tOLP: lock duration must be > 0");
newEpoch
so these timing constraints can be achievedThe end result of all of this is that users can purchase a significant quantity of discounted TAP without having to commit to a long lock time.
Apologies for not including a hardhat test case to validate this. I did try for quite some time but Hardhat makes it really difficult to work with the timings of exact transactions compared to foundry in my opinion.
Manual review
I suggest two main changes to mitigate this issue:
TapiocaOptionLiquidityProvision.sol
should be a multiple of the epoch duration (1 week). This both enforces a minimum lock time and ensure that users aren't gaming the timing of epochs by locking for just more than the duration of a whole epoch.Timing
#0 - c4-pre-sort
2023-08-06T01:18:55Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-15T20:44:08Z
0xRektora marked the issue as disagree with severity
#2 - 0xRektora
2023-08-15T20:46:29Z
Valid issue, but since it doesn't hurt the protocol or user funds, should be marked as medium.
#3 - c4-sponsor
2023-08-20T15:24:50Z
0xRektora marked the issue as sponsor confirmed
#4 - c4-judge
2023-09-18T13:59:04Z
dmvt changed the severity to 2 (Med Risk)
#5 - c4-judge
2023-09-18T13:59:12Z
dmvt marked the issue as selected for report
#6 - 0xRektora
2023-10-03T16:07:52Z
Part of the issue has been solved in #1101. The other part would not be solved. One one user can initiate the newEpoch
call, and we'd call it MEV to call this function as fast as possible.
🌟 Selected for report: dirk_y
Also found by: KIntern_NA, SaeedAlipoor01988, ltyu
183.7708 USDC - $183.77
https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionBroker.sol#L430 https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionBroker.sol#L508-L536 https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionBroker.sol#L551-L555
At the moment both the TapiocaOptionBroker.sol
contract and the AirdropBroker.sol
contract assume the number of decimals returned from the TAP oracle and the payment token oracle are identical.
However this is not guaranteed to be true. If the number of decimals returned by the oracles is different then the discounted payment token amount to pay when exercising options is either orders of magnitude too large or orders of magnitude too small.
When a new epoch is started by calling newEpoch
the current TAP value is fetched from the TAP oracle and remains constant for the epoch:
(, epochTAPValuation) = tapOracle.get(tapOracleData);
Let's assume this oracle uses 8 decimals.
When a user wants to exercise an option they call exerciseOption
which calls _processOTCDeal
under the hood:
function _processOTCDeal( ERC20 _paymentToken, PaymentTokenOracle memory _paymentTokenOracle, uint256 tapAmount, uint256 discount ) internal { // Get TAP valuation uint256 otcAmountInUSD = tapAmount * epochTAPValuation; // Get payment token valuation (, uint256 paymentTokenValuation) = _paymentTokenOracle.oracle.get( _paymentTokenOracle.oracleData ); // Calculate payment amount and initiate the transfers uint256 discountedPaymentAmount = _getDiscountedPaymentAmount( otcAmountInUSD, paymentTokenValuation, discount, _paymentToken.decimals() ); _paymentToken.transferFrom( msg.sender, address(this), discountedPaymentAmount ); tapOFT.extractTAP(msg.sender, tapAmount); }
Given the TAP token has 18 decimals, if we wanted to purchase 1 TAP where the value of TAP was 1USD then otcAmountInUSD = 1e18 * 1e8 = 1e26
.
Next we get the paymentTokenValuation
. Let's assume that this oracle uses 6 decimals and that the payment token also had a value of 1 USD and has 18 decimals. The return value would be 1e6.
Now _getDiscountedPaymentAmount
is called to calculate the discounted amount:
function _getDiscountedPaymentAmount( uint256 _otcAmountInUSD, uint256 _paymentTokenValuation, uint256 _discount, uint256 _paymentTokenDecimals ) internal pure returns (uint256 paymentAmount) { // Calculate payment amount uint256 rawPaymentAmount = _otcAmountInUSD / _paymentTokenValuation; paymentAmount = rawPaymentAmount - muldiv(rawPaymentAmount, _discount, 100e4); // 1e4 is discount decimals, 100 is discount percentage paymentAmount = paymentAmount / (10 ** (18 - _paymentTokenDecimals)); }
With the assumed values above rawPaymentAmount = 1e26 / 1e6 = 1e20
. For ease of explanation let's skip the discount calculation to the final line where paymentAmount = 1e20 / 10 ** (18 - 18) = 1e20
.
So the final value of payment tokens we could be sending to purchase 1e18 TAP tokens is (of the order of) 1e20 payment tokens. Given both tokens are of the same underlying USD value, we're overpaying for the TAP tokens by 100x. Of course, this multiplier changes depending on difference in oracle decimals.
Right now most oracles use 8 decimals when pricing against the US Dollar, so it's fair to assume the Seer.sol
will use 8 decimals to reflect the underlying oracles, however Tapioca should be built to work gracefully in any decimal environment as market conditions change over the next 5-10 years.
Manual review
The number of decimals used by the oracles should be included in the payment token amount calculations when exercising options. Below is a diff to the TapiocaOptionBroker.sol
contract, where the same changes should also be applied to the AirdropBroker.sol
contract:
diff --git a/contracts/options/TapiocaOptionBroker.sol b/contracts/options/TapiocaOptionBroker.sol index f359ff7..256be64 100644 --- a/contracts/options/TapiocaOptionBroker.sol +++ b/contracts/options/TapiocaOptionBroker.sol @@ -524,7 +524,8 @@ contract TapiocaOptionBroker is Pausable, BoringOwnable, TWAML { otcAmountInUSD, paymentTokenValuation, discount, - _paymentToken.decimals() + _paymentToken.decimals(), + _paymentTokenOracle.oracle.decimals() ); _paymentToken.transferFrom( @@ -545,14 +546,15 @@ contract TapiocaOptionBroker is Pausable, BoringOwnable, TWAML { uint256 _otcAmountInUSD, uint256 _paymentTokenValuation, uint256 _discount, - uint256 _paymentTokenDecimals + uint256 _paymentTokenDecimals, + uint256 _paymentTokenOracleDecimals ) internal pure returns (uint256 paymentAmount) { // Calculate payment amount uint256 rawPaymentAmount = _otcAmountInUSD / _paymentTokenValuation; paymentAmount = rawPaymentAmount - muldiv(rawPaymentAmount, _discount, 100e4); // 1e4 is discount decimals, 100 is discount percentage - paymentAmount = paymentAmount / (10 ** (18 - _paymentTokenDecimals)); + paymentAmount = paymentAmount * (10 ** _paymentTokenOracleDecimals) / (10 ** ((18 - _paymentTokenDecimals) + tapOracle.decimals())); } /// @notice Emit TAP to the gauges equitably
Oracle
#0 - c4-pre-sort
2023-08-06T00:59:04Z
minhquanym marked the issue as primary issue
#1 - minhquanym
2023-08-06T00:59:24Z
Oracle decimal is not taken into account
#2 - c4-sponsor
2023-08-18T21:17:22Z
0xRektora marked the issue as sponsor confirmed
#3 - c4-judge
2023-09-29T18:15:07Z
dmvt marked the issue as selected for report
🌟 Selected for report: 0xWaitress
Also found by: Ack, BPZ, Breeje, LosPollosHermanos, Madalad, Nyx, Ruhum, SaeedAlipoor01988, ayeslick, c7e7eff, carrotsmuggler, cergyk, dirk_y, hack3r-0m, iglyx, kaden, kodyvim, kutugu, ladboy233, ltyu, mojito_auditor, n1punp, rvierdiiev, unsafesol, zzzitron
2.1417 USDC - $2.14
https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/Penrose.sol#L232-L236 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/Penrose.sol#L529-L534
As the owner of all the singularity markets, the Penrose contract has the ability to withdraw all the market fees and send these fees to the fee distributor contract. The entrypoint for this functionality is withdrawAllMarketFees
.
Since the method is public a malicious user can call this method with a combination of the worst swapper and a swapData_.minAssetAmount
of 0. This is particularly important during periods where there might be a bank run or low liquidity in a trading pool (e.g. UniswapV2) since the exchange rate can be significantly impacted. It is possible that the markets could have accumulated a significant number of fees that a malicious user can then wipe out by withdrawing the fees with unlimited slippage tolerance.
As you can see, the withdrawAllMarketFees
method is callable by anyone and allows the user to specify swapData_
:
function withdrawAllMarketFees( IMarket[] calldata markets_, ISwapper[] calldata swappers_, IPenrose.SwapData[] calldata swapData_ ) public notPaused {
Where swapData_
is:
struct SwapData { uint256 minAssetAmount; }
The withdrawAllMarketFees
method calls _depositFeesToYieldBox
for every market specified, where the swapper contract is then called with the minAssetAmount
value passed in by the caller:
(amount, ) = swapper.swap( swapData, dexData.minAssetAmount, feeTo, "" );
You can easily see how any malicious user could specify a minAssetAmount
value of 0 to withdraw accumulated fees at a significant loss to the protocol. If the protocol were acting in their best interest they would only withdraw the protocol fees when swapping in DEXs was at a favourable slippage. This can't be guaranteed with the current code.
Manual review
I can see the appeal for having the withdrawAllMarketFees
callable by anyone. Therefore my suggestion would be remove the swapData_.minAssetAmount
argument from the function call and instead use a maxSlippageTolerance
parameter that is set by the Penrose owner as a state variable in the constructor and is modifiable in a dedicated onlyOwner
method. This maxSlippageTolerance
parameter can calculate the minAmountOut
on the fly based on the number of shares being withdrawn when the swap is performed by the swapper contract.
Usually you want the slippage to be very small, but this way the admin has the option to increase the slippage tolerance in turbulent market scenarios if they are willing to accept a lower amount out from an DEX swap.
Uniswap
#0 - c4-pre-sort
2023-08-06T06:05:25Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-20T15:05:38Z
0xRektora marked the issue as sponsor confirmed
#2 - c4-judge
2023-09-19T11:43:53Z
dmvt marked the issue as duplicate of #245
#3 - c4-judge
2023-09-22T22:20:08Z
dmvt marked the issue as satisfactory