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: 54/132
Findings: 6
Award: $823.13
π Selected for report: 1
π Solo Findings: 0
π Selected for report: GalloDaSballo
Also found by: 0xfuje, GalloDaSballo, Ruhum, Vagner, jasonxiale, kutugu, mojito_auditor
58.8874 USDC - $58.89
https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/compound/CompoundStrategy.sol#L115 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/compound/CompoundStrategy.sol#L145 https://github.com/Tapioca-DAO/YieldBox/blob/f5ad271b2dcab8b643b7cf622c2d6a128e109999/contracts/YieldBox.sol#L130 https://github.com/Tapioca-DAO/YieldBox/blob/f5ad271b2dcab8b643b7cf622c2d6a128e109999/contracts/YieldBox.sol#L279
The Compound strategy uses the exchange rate calculated without accruing the interest first which can be smaller than the actual one. It depends on whether interest was already accrued in the given block. That will lead to the strategy to return a smaller number for its current balance.
The value is used by YieldBox to compute the number of shares to mint/burn when a user deposits/withdraws. For deposits that means the user gets more shares than they should while on withdrawals you receive fewer assets than you should.
The Compound strategy uses exchangeRateStored()
to get the cToken's exchange rate:
function _currentBalance() internal view override returns (uint256 amount) { uint256 shares = cToken.balanceOf(address(this)); uint256 pricePerShare = cToken.exchangeRateStored(); uint256 invested = (shares * pricePerShare) / (10 ** 18); uint256 queued = wrappedNative.balanceOf(address(this)); return queued + invested; }
Instead, it should use exchangeRateCurrent()
which accrues the interest for the pool before calculating the exchange rate: https://github.com/compound-finance/compound-protocol/blob/master/contracts/CToken.sol#L270C1-L286C6 also see docs
The strategy's balance is used in the deposit and withdrawal flow of YieldBox:
none
Use exchangeRateCurrent()
instead of exchangeRateStored()
.
Other
#0 - c4-pre-sort
2023-08-06T12:42:25Z
minhquanym marked the issue as duplicate of #411
#1 - c4-pre-sort
2023-08-06T12:44:58Z
minhquanym marked the issue as duplicate of #1330
#2 - c4-judge
2023-09-26T14:59:14Z
dmvt changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-09-26T14:59:25Z
dmvt marked the issue as satisfactory
π Selected for report: GalloDaSballo
Also found by: KIntern_NA, Ruhum, mojito_auditor, rvierdiiev
101.7807 USDC - $101.78
If TapOFT is paused for more than two weeks, you won't be able to call emitForWeek()
. That will cause
dso_supply
to be inflated///-- Write methods -- /// @notice Emit the TAP for the current week /// @return the emitted amount function emitForWeek() external notPaused returns (uint256) { require(_getChainId() == governanceChainIdentifier, "chain not valid"); uint256 week = _timestampToWeek(block.timestamp); if (emissionForWeek[week] > 0) return 0; // Update DSO supply from last minted emissions dso_supply -= mintedInWeek[week - 1]; // @audit if contract is paused for over a week, the minted tokens in a week // aren't subtracted from dso_supply. // @audit if contract is paused for over a week, the remaining emissions // from the previous week won't be added to the current week's emissions. // Compute unclaimed emission from last week and add it to the current week emission uint256 unclaimed = emissionForWeek[week - 1] - mintedInWeek[week - 1]; uint256 emission = uint256(_computeEmission()); emission += unclaimed; emissionForWeek[week] = emission; emit Emitted(week, emission); return emission; }
Given that $100,000$ tokens were emitted in week $X$. Before the week ends the contract is paused. It's unpaused in week $X + 2$. emitForWeek()
is executed:
dso_supply -= mintedInWeek[week - 1];
week == X+2
, it will try to subtract mintedInWeek[x + 1]
from dso_supply
. But, no rewards were emitted in that week. The value will be 0. Thus, the funds emitted in week $X$ aren't subtracted from dso_supply
.uint256 unclaimed = emissionForWeek[week - 1] - mintedInWeek[week - 1];
week - 1 == x + 1
, where emissionForWeek[x + 1] == 0
and mintedInWeek[x + 1] == 0
. Any unclaimed funds in week $X$ are not rolled over to the current epoch.none
The contract should keep track of the current week and that variable for the calculation:
function emitForWeek() external notPaused returns (uint256) { require(_getChainId() == governanceChainIdentifier, "chain not valid"); uint256 week = _timestampToWeek(block.timestamp); if (emissionForWeek[week] > 0) return 0; // Update DSO supply from last minted emissions dso_supply -= mintedInWeek[currentWeek]; // @audit if contract is paused for over a week, the minted tokens in a week // aren't subtracted from dso_supply. // @audit if contract is paused for over a week, the remaining emissions // from the previous week won't be added to the current week's emissions. // Compute unclaimed emission from last week and add it to the current week emission uint256 unclaimed = emissionForWeek[currentWeek] - mintedInWeek[currentWeek]; uint256 emission = uint256(_computeEmission()); emission += unclaimed; emissionForWeek[week] = emission; currentWeek = week; emit Emitted(week, emission); return emission; }
Other
#0 - c4-pre-sort
2023-08-04T23:00:21Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-21T18:59:14Z
0xRektora marked the issue as sponsor confirmed
#2 - c4-judge
2023-09-29T22:43:29Z
dmvt marked issue #1218 as primary and marked this issue as a duplicate of 1218
#3 - c4-judge
2023-09-29T22:43:34Z
dmvt marked the issue as satisfactory
π 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/compound/CompoundStrategy.sol#L100 https://github.com/Tapioca-DAO/YieldBox/blob/f5ad271b2dcab8b643b7cf622c2d6a128e109999/contracts/YieldBox.sol#L155 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/compound/CompoundStrategy.sol#L123 https://github.com/Tapioca-DAO/YieldBox/blob/f5ad271b2dcab8b643b7cf622c2d6a128e109999/contracts/strategies/BaseStrategy.sol#L36
The owner of a YieldBox strategy can withdraw all of the deposited funds in case of an emergency. But, right after anybody can redeposit those funds back into the strategy because the admin isn't able to pause deposits.
We can assume that these funds are potentially at risk of being lost because the admin engaged in an emergency withdrawal. An attacker of the underlying protocol could bundle a tx where they redeposit the strategies funds into the underlying protocol to execute their attack.
Every YieldBox strategy has an emergencyWithdraw()
function:
function emergencyWithdraw() external onlyOwner returns (uint256 result) { compound(""); uint256 toWithdraw = cToken.balanceOf(address(this)); cToken.redeem(toWithdraw); INative(address(wrappedNative)).deposit{value: address(this).balance}(); result = address(this).balance; }
All of the deposited funds are withdrawn from the underlying protocol and left in the strategy contract. Anybody is able to redeposit those funds by depositing 1 wei into YieldBox. That'll trigger the strategy's deposited()
function which will deposit all available funds:
function depositAsset( uint256 assetId, address from, address to, uint256 amount, uint256 share ) public allowed(from, assetId) returns (uint256 amountOut, uint256 shareOut) { // ... asset.strategy.deposited(amount); emit Deposited(msg.sender, from, to, assetId, amount, share, amountOut, shareOut, false); return (amount, share); }
function _deposited(uint256 amount) internal override nonReentrant { uint256 queued = wrappedNative.balanceOf(address(this)); if (queued > depositThreshold) { INative(address(wrappedNative)).withdraw(queued); cToken.mint{value: queued}(); emit AmountDeposited(queued); return; } emit AmountQueued(amount); }
There's no way for the admin to remove the funds from the strategy contract. That's only possible by burning YieldBox shares through its withdraw()
function. They also can't pause the YieldBox's deposit()
function to prevent the user from re-deposting.
This issue affects all existing strategies.
none
Make strategies pausable.
Other
#0 - c4-pre-sort
2023-08-06T05:47:26Z
minhquanym marked the issue as duplicate of #1522
#1 - c4-judge
2023-09-18T19:52:34Z
dmvt marked the issue as satisfactory
π Selected for report: Ruhum
Also found by: KIntern_NA
453.755 USDC - $453.76
https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionLiquidityProvision.sol#L247 https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionBroker.sol#L385
tOLP tokens that are not unlocked after they have expired cause the reward distribution to be flawed. Rewards are distributed to expired locks and are not claimable.
In TapiocaOptionBroker.exerciseOption()
, the caller's rewards are calculated through the total pool deposits and their share of it:
uint256 eligibleTapAmount = muldiv( tOLPLockPosition.amount, gaugeTotalForEpoch, tOLP.getTotalPoolDeposited(tOLPLockPosition.sglAssetID) );
But, totalDeposited
includes locks that have already expired. The user has to unlock their position for totalDeposited
to be reduced:
function getTotalPoolDeposited( uint256 _sglAssetId ) external view returns (uint256) { return activeSingularities[sglAssetIDToAddress[_sglAssetId]] .totalDeposited; } /// @notice Unlocks tOLP tokens /// @param _tokenId ID of the position to unlock /// @param _singularity Singularity market address /// @param _to Address to send the tokens to function unlock( uint256 _tokenId, IERC20 _singularity, address _to ) external returns (uint256 sharesOut) { require(_exists(_tokenId), "tOLP: Expired position"); LockPosition memory lockPosition = lockPositions[_tokenId]; require( block.timestamp >= lockPosition.lockTime + lockPosition.lockDuration, "tOLP: Lock not expired" ); require( activeSingularities[_singularity].sglAssetID == lockPosition.sglAssetID, "tOLP: Invalid singularity" ); require( _isApprovedOrOwner(msg.sender, _tokenId), "tOLP: not owner nor approved" ); _burn(_tokenId); delete lockPositions[_tokenId]; // Transfer the tOLR tokens back to the owner sharesOut = yieldBox.toShare( lockPosition.sglAssetID, lockPosition.amount, false ); yieldBox.transfer( address(this), _to, lockPosition.sglAssetID, sharesOut ); // @audit totalDeposited is used for calculating the share of TAP rewards // of each user. If a user doesn't unlock their expired position, they are still assigned // rewards that they can't exercise. // Thus, they take away funds from actual participants. activeSingularities[_singularity].totalDeposited -= lockPosition.amount; emit Burn(_to, lockPosition.sglAssetID, lockPosition); }
none
Currently, there's no way to force the user to unlock their position. There's no real incentive for them to unlock it immediately either. They can "park" the funds there.
If the user doesn't manually unlock their position after it has expired, it should be possible for third parties to do it for a small reward, e.g. 1% of the position.
That should incentivize users to unlock as early as possible.
Math
#0 - c4-pre-sort
2023-08-07T02:14:27Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-16T19:19:46Z
0xRektora marked the issue as sponsor acknowledged
#2 - c4-sponsor
2023-08-16T19:23:52Z
0xRektora marked the issue as sponsor confirmed
#3 - c4-sponsor
2023-08-16T19:24:23Z
0xRektora marked the issue as disagree with severity
#4 - 0xRektora
2023-08-16T19:24:49Z
Users funds are not at loss, should be medium
.
#5 - c4-judge
2023-09-21T15:34:51Z
dmvt changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-09-21T15:35:08Z
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-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/stargate/StargateStrategy.sol#L182 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/convex/ConvexTricryptoStrategy.sol#L207 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/convex/ConvexTricryptoStrategy.sol#L336 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/aave/AaveStrategy.sol#L193
In multiple places, the minOut
value for a swap is calculated inside the smart contract. That doesn't work. At the point where your calculation happens, the price has already been manipulated. You're not protecting yourself against sandwiches that way.
All of these swaps will probably be sandwiched causing a loss of funds for users.
Here's an example from the AAVE strategy:
uint256 aaveAmount = aaveBalanceAfter - aaveBalanceBefore; //swap AAVE to wrappedNative ISwapper.SwapData memory swapData = swapper.buildSwapData( address(rewardToken), address(wrappedNative), aaveAmount, 0, false, false ); uint256 calcAmount = swapper.getOutputAmount(swapData, ""); uint256 minAmount = calcAmount - (calcAmount * 50) / 10_000; //0.5% swapper.swap(swapData, minAmount, address(this), "");
Given that 1 ETH = 1,800 USDC:
minOut
calculation would result in:
calcAmount = 1,000 USDC
because the price was manipulated.minAmount = 1,000 USDC - 1,000 USDC * 50 / 10,000 = 995 USDC
By calculating the expected output amount on-chain, you're using already manipulated data. The minOut
has to be calculated off-chain instead. Or, you use an oracle to get the real price and calculate minOut
with that.
none
Use an oracle to get the real price of an asset and calculate minOut
using that.
MEV
#0 - c4-pre-sort
2023-08-08T03:35:09Z
minhquanym marked the issue as duplicate of #245
#1 - c4-judge
2023-09-22T22:16:54Z
dmvt marked the issue as satisfactory