Platform: Code4rena
Start Date: 13/11/2023
Pot Size: $24,500 USDC
Total HM: 3
Participants: 120
Period: 4 days
Judge: 0xTheC0der
Id: 306
League: ETH
Rank: 6/120
Findings: 2
Award: $897.48
🌟 Selected for report: 0
🚀 Solo Findings: 0
690.3741 USDC - $690.37
The owner of an asD
token can only withdraw carry
.
To facilitate this, we computed the maximum available carry
amount that the owner can withdraw.
However, this calculated value should be divided by 1e18
not 1e28
.
Consequently, our calculation yields an incorrect carry
amount—significantly smaller than the actual amount.
As a result, the owner can withdraw only a small portion, or the transaction may revert due to underflow.
I have reviewed the CNote tokens. The hierarchy is as follows:
contract CNote is CErc20Delegate {} contract CErc20Delegate is CErc20, CDelegateInterface {} contract CErc20 is CToken, CErc20Interface {} abstract contract CToken is CTokenInterface, ExponentialNoError, TokenErrorReporter {}
The scale factor expScale
value is defined as a constant, and its value is set to 1e18.
contract ExponentialNoError { uint constant expScale = 1e18; }
When attempting to retrieve the exchangeRateCurrent
value, the following function in the CToken
will be invoked:
function exchangeRateCurrent() public override nonReentrant returns (uint) { accrueInterest(); return exchangeRateStored(); } function exchangeRateStored() public view override returns (uint) { return exchangeRateStoredInternal(); }
Consequently, this will trigger the exchangeRateStoredInternal
function in CNote
.
function exchangeRateStoredInternal() internal view virtual override returns (uint256) { uint256 _totalSupply = totalSupply; if (_totalSupply == 0) { return initialExchangeRateMantissa; } else { uint256 cashPlusBorrowsMinusReserves = totalBorrows - totalReserves; // totalCash in cNote Lending Market is zero, thus it is not factored into the exchangeRate uint256 exchangeRate = (cashPlusBorrowsMinusReserves * expScale) / _totalSupply; return exchangeRate; } }
Here, the _totalSupply
value is denominated in cToken
's decimals
, while cashPlusBorrowsMinusReserves
is denominated in underlying token
's decimals
.
We can track these values in the following functions:
function mintFresh(address minter, uint mintAmount) internal virtual { ... totalSupply = totalSupply + mintTokens; } function borrowFresh(address payable borrower, uint borrowAmount) internal virtual { ... uint totalBorrowsNew = totalBorrows + borrowAmount; totalBorrows = totalBorrowsNew; }
The decimals
for all cTokens
are 8
, and the decimal
for our note
token is 18
.
Let me provide an example.
Suppose the cNote
token balance is 1e8
(equivalent to 1 CNote
), and the underlying token
balance is 1e18
(equivalent to 1 Note
).
In this case, the exchangeRateCurrent
value is calculated as 1e28
(which equals 1e18 * expScale / 1e8
).
In our withdrawCarry
function, we can obtain this value from the following line:
uint256 exchangeRate = CTokenInterface(cNote).exchangeRateCurrent();
Then (CTokenInterface(cNote).balanceOf(address(this)) * exchangeRate) / 1e28 = 1e8 * 1e28 / 1e28 = 1e8
.
However, the actual balance of the underlying token
is 1e18
.
I believe the meaning of Scaled by 1 * 10^(18 - 8 + Underlying Token Decimals), i.e., 10^(28) in our case
is as follows:
1e18
is the scale factor.(Underlying Token Decimal - 8)
is related to the decimals
of totalBorrows
and totalSupply
.In short, all calculations to convert between cToken
and underlying tokens
are based on 1e18(expScale)
.
i.e.
underlying token balance = cToken balance * exchangeRateCurrent / 1e18
Furthermore, we can confirm this by examining the balanceOfUnderlying
function.
function balanceOfUnderlying( address owner ) external override returns (uint) { Exp memory exchangeRate = Exp({mantissa: exchangeRateCurrent()}); return mul_ScalarTruncate(exchangeRate, accountTokens[owner]); } function mul_ScalarTruncate(Exp memory a, uint scalar) internal pure returns (uint) { Exp memory product = mul_(a, scalar); return truncate(product); } function mul_(Exp memory a, uint b) internal pure returns (Exp memory) { return Exp({mantissa: mul_(a.mantissa, b)}); } function mul_(uint a, uint b) internal pure returns (uint) { return a * b; } function truncate(Exp memory exp) internal pure returns (uint) { // Note: We are not using careful math here as we're performing a division that cannot fail return exp.mantissa / expScale; }
Please modify withdrawCarry
function as follows:
function withdrawCarry(uint256 _amount) external onlyOwner { - uint256 exchangeRate = CTokenInterface(cNote).exchangeRateCurrent(); - uint256 maximumWithdrawable = (CTokenInterface(cNote).balanceOf(address(this)) * exchangeRate) / - 1e28 - - totalSupply(); + uint256 maximumWithdrawable = CTokenInterface(cNote).balanceOfUnderlying(address(this)); }
or divide by 1e18
instead of 1e28
.
Decimal
#0 - c4-pre-sort
2023-11-18T05:11:52Z
minhquanym marked the issue as duplicate of #227
#1 - c4-judge
2023-11-28T22:55:27Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: Krace
Also found by: 0xAadi, 0xpiken, AS, D1r3Wolf, PENGUN, SpicyMeatball, Yanchuan, bin2chen, d3e4, ether_sky, glcanvas, immeas, lanrebayode77, leegh, mojito_auditor, rvierdiiev, tnquanghuy0512
207.1122 USDC - $207.11
Based on the system design, users are unable to claim fees associated with this purchase when attempting to buy tokens. So, these fees should be distributed among other holders, the platform, and the creator of that share. However, a portion will be stuck in the market, and nobody can utilize it due to the incorrect implementation of the fee split logic.
To prevent users from claiming fees from their new purchase, the unclaimed fees are calculated for that user before splitting the new fee. After splitting this new fee, the user is then updated that there are no pending fees anymore.
uint256 rewardsSinceLastClaim = _getRewardsSinceLastClaim(_id); // Split the fee among holder, creator and platform _splitFees(_id, fee, shareData[_id].tokensInCirculation); rewardsLastClaimedValue[_id][msg.sender] = shareData[_id].shareHolderRewardsPerTokenScaled; SafeERC20.safeTransfer(token, msg.sender, rewardsSinceLastClaim);
The new fee is divided among token holders based on the number of tokens in circulation, represented by shareData[_id].tokensInCirculation
.
_splitFees(_id, fee, shareData[_id].tokensInCirculation);
function _splitFees(uint256 _id, uint256 _fee, uint256 _tokenCount) internal { if (_tokenCount > 0) { shareData[_id].shareHolderRewardsPerTokenScaled += (shareHolderFee * 1e18) / _tokenCount; } }
However, this value includes the tokens held by the current user.
Consequently, a portion is allocated to that user if their token count is non-zero.
But the rewardsLastClaimedValue
variable of that user is updated with the latest shareHolderRewardsPerTokenScaled
value without claiming this fee.
_splitFees(_id, fee, shareData[_id].tokensInCirculation); rewardsLastClaimedValue[_id][msg.sender] = shareData[_id].shareHolderRewardsPerTokenScaled;
As a result, these fees become stuck in the market.
Please add below test to Market.t.sol
.
function testBut2Times() public { testCreateNewShare(); token.approve(address(market), 1e18); uint256 price1 = LINEAR_INCREASE; uint256 fee1 = LINEAR_INCREASE / 10; market.buy(1, 1); assertEq(token.balanceOf(address(market)), price1 + fee1); uint256 price2 = 2 * LINEAR_INCREASE; uint256 fee2 = (2 * LINEAR_INCREASE) / 10; market.buy(1, 1); assertEq(token.balanceOf(address(market)), price1 + fee1 + price2 + fee2); uint256 balBefore = token.balanceOf(address(this)); uint256 balBobBefore = token.balanceOf(address(bob)); uint256 creatorFee = (fee1 * 33) / 100 + (fee2 * 33) / 100; uint256 platformFee = (fee1 * 67) / 100 + (fee2 * 34) / 100; uint256 holderFee = 0; uint256 remainingFee = fee1 + fee2 - creatorFee - platformFee; // = fee2 * 33 / 100 vm.prank(bob); market.claimCreatorFee(1); assertEq(token.balanceOf(address(bob)), balBobBefore + creatorFee); vm.prank(address(this)); market.claimPlatformFee(); assertEq(token.balanceOf(address(address(this))), balBefore + platformFee); market.claimHolderFee(1); assertEq(token.balanceOf(address(address(this))), balBefore + platformFee + holderFee); // The remaining fee is currently stuck in the market, and no one can utilize it. assertEq(token.balanceOf(address(market)), price1 + price2 + remainingFee); }
Please modify buy
function as below:
function buy(uint256 _id, uint256 _amount) external { - _splitFees(_id, fee, shareData[_id].tokensInCirculation); + _splitFees(_id, fee, shareData[_id].tokensInCirculation - tokensByAddress[_id][msg.sender]); }
Error
#0 - c4-pre-sort
2023-11-18T04:13:20Z
minhquanym marked the issue as duplicate of #302
#1 - c4-judge
2023-11-28T22:40:45Z
MarioPoneder marked the issue as satisfactory
#2 - c4-judge
2023-11-28T23:54:04Z
MarioPoneder marked the issue as duplicate of #9