-
Calculating
maximumWithdrawable
will underflow and revert due to incorrect scaling. -
Contract owner won't be able to withdraw earned interests with the
withdrawCarry()
function.
Creators can create stablecoins pegged to $NOTE with this protocol and they can earn interest with their creations.
Here is how the minting/burning of these stablecoins and interest mechanism works:
-
Users call
asD::mint()
and transfer their $NOTE tokens to mintasD
stablecoin -
asD
contract immediately deposits these $NOTE tokens to Canto Lending Market and mintscNOTE
, which earns interest. -
Users get a corresponding amount of
asD
stablecoin
The cNOTE
tokens in the lending market earn interest according to exchangeRate
which is always increasing.
The opposite happens when burning.
-
Users request their $NOTE tokens by calling
asD::burn()
-
The
asD
contract redeems that amount of $NOTE token from the Canto Lending Market and burnscNOTE
tokens according to that moment's exchange rate. -
Because the exchange rate is increasing, the burned (redeemed) cNOTE amount is less than the minted amount, and the difference is the interest that the creator earns.
The asD
contract creator can withdraw these earned interests by calling the asD::withdrawCarry()
function:
https://github.com/code-423n4/2023-11-canto/blob/335930cd53cf9a137504a57f1215be52c6d67cb3/asD/src/asD.sol#L72
file:asD.sol
function withdrawCarry(uint256 _amount) external onlyOwner {
--> uint256 exchangeRate = CTokenInterface(cNote).exchangeRateCurrent(); // Scaled by 1 * 10^(18 - 8 + Underlying Token Decimals), i.e. 10^(28) in our case //@audit-issue According to cToken source code it is scaled by 1e18 - NOT 1e28
// The amount of cNOTE the contract has to hold (based on the current exchange rate which is always increasing) such that it is always possible to receive 1 NOTE when burning 1 asD
uint256 maximumWithdrawable = (CTokenInterface(cNote).balanceOf(address(this)) * exchangeRate) /
--> 1e28 -
totalSupply();
if (_amount == 0) {
_amount = maximumWithdrawable;
} else {
require(_amount <= maximumWithdrawable, "Too many tokens requested");
}
// skipped for brevity
}
This function basically checks the current exchange rate, calculates the $NOTE value of the current cNOTE
balances with this exchange rate, and finds the maximumWithdrawable
amount as interest.
In this function, the exchange rate is assumed to be scaled by 1e28
as you can see in this comment "Scaled by 1 * 10^(18 - 8 + Underlying Token Decimals), i.e. 10^(28) in our case
", this division.
This comment is true based on the compound docs. However, this is not the case in the actual source code. In the code itself, the returned exchange rate is always scaled by 1e18.
file: CToken.sol //compound
/**
* @notice Accrue interest then return the up-to-date exchange rate
* @return Calculated exchange rate scaled by 1e18
*/
function exchangeRateCurrent() override public nonReentrant returns (uint) {
accrueInterest();
return exchangeRateStored();
}
Canto Lending Market is a compound fork and it returns the exchange rate scaled by 1e18 too. It can also be checked by reading the contract in the Canto blockchain explorers.
cNOTE
contract address is: 0xEe602429Ef7eCe0a13e4FfE8dBC16e101049504C
It can be checked here (function no 40): https://tuber.build/token/0xEe602429Ef7eCe0a13e4FfE8dBC16e101049504C?tab=read_contract
You can see that the live cNOTE contract returns the exchange rate with 18 decimals in the screenshot below:
https://user-images.githubusercontent.com/97894167/283773543-a4d64810-474d-4ff4-8b9d-3b60682f18e6.png
The maximumWithdrawable
amount calculation will always underflow due to the exchange rate being 18 decimals but the calculation is made with a hardcoded 1e28
value.
uint256 maximumWithdrawable = (CTokenInterface(cNote).balanceOf(address(this)) * exchangeRate) /
--> 1e28 -
totalSupply(); //@audit will underflow due to 1e8 - 1e18
Manual review
I would recommend using 1e18 when scaling the exchange rate instead of 1e28
Note: The original submission can be found here.