Owner cannot withdraw all interest due to wrong calculation of accrued interest in WithdrwaCarry
criticalLines of code
Vulnerability details
Impact
The current withdrawCarry function in the contract underestimates the accrued interest due to a miscalculation. This error prevents the rightful owner from withdrawing their accrued interest, effectively locking the assets. The primary issue lies in the calculation of maximumWithdrawable within withdrawCarry.
Flawed Calculation:
The following code segment is used to determine maximumWithdrawable:
uint256 exchangeRate = CTokenInterface(cNote).exchangeRateCurrent(); // Scaled by 1 * 10^(18 - 8 + Underlying Token Decimals), i.e. 10^(28) in our case
// 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();
The problem arises with the scaling of exchangeRate, which is assumed to be scaled by 10^28. However, for CNOTE in the Canto network, the exchangeRate is actually scaled by 10^18. This discrepancy causes the owner to withdraw only 10^(-10) times the actual interest amount. Consequently, when a non-zero _amount is specified, withdrawCarry often fails due to the require(_amount <= maximumWithdrawable, "Too many tokens requested"); condition.
Proof of Concept
CNOTE Scaling Verification
An essential aspect of this audit involves verifying the scaling factor of the CNOTE exchange rate. The exchangeRate scale for CNOTE can be verified in the Canto Network's GitHub repository. Evidence confirming that the exponent of the CNOTE exchange rate is indeed 18 can be found through this link to the token tracker. The data provided there shows the current value of the stored exchange rate (exchangeRateStored) as approximately 1004161485744613000. This value corresponds to 1.00416 * 1e18, reaffirming the 10^18 scaling factor.
This information is critical for accurately understanding the mechanics of CNOTE and ensuring that the smart contract's calculations align with the actual scaling used in the token's implementation. The verification of this scaling factor supports the recommendations for adjusting the main contract's calculations and the associated test cases, as previously outlined.
Testing with Solidity Codes
Testing with the following Solidity code illustrates the actual CNOTE values:
solidityfunction updateBalance() external { updatedUnderlyingBalance = ICNoteSimple(cnote).balanceOfUnderlying(msg.sender); updatedExchangeRate = ICNoteSimple(cnote).exchangeRateCurrent(); uint256 balance = IERC20(cnote).balanceOf(msg.sender); calculatedUnderlying = balance * updatedExchangeRate / 1e28; }
The corresponding TypeScript logs show a clear discrepancy between the expected and calculated underlying balances,
console.log("balanceCnote: ", (Number(balanceCnote) / 1e18).toString());
console.log("exchangeRate: ", Number(exchangeRate).toString());
console.log("underlyingBalance: ", Number(underlyingBalance).toString());
console.log("calculatedUnderlying: ", Number(calculatedUnderlying).toString());
with the logs
balanceCnote: 400100.9100006097
exchangeRate: 1004122567006264000
underlyingBalance: 4.017503528113544e+23
calculatedUnderlying: 40175035281135
Tools Used
- Solidity for interacting with the Canto mainnet.
- TypeScript for testing and validation.
Recommended Mitigation Steps
Using balanceOfUnderlying Function
Replace the flawed calculation with the balanceOfUnderlying function. This function accurately calculates the underlying NOTE balance and is defined in CToken.sol (source).
Proposed Code Modifications: Two alternative implementations are suggested:
- Without
balanceOfUnderlying: Modify the scaling factor in the existing calculation from1e28to1e18.
function withdrawCarry(uint256 _amount) external onlyOwner {
uint256 exchangeRate = CTokenInterface(cNote).exchangeRateCurrent(); // Scaled by 10^18
// 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) /
1e18 -
totalSupply();
if (_amount == 0) {
_amount = maximumWithdrawable;
} else {
require(_amount <= maximumWithdrawable, "Too many tokens requested");
}
// Technically, _amount can still be 0 at this point, which would make the following two calls unnecessary.
// But we do not handle this case specifically, as the only consequence is that the owner wastes a bit of gas when there is nothing to withdraw
uint256 returnCode = CErc20Interface(cNote).redeemUnderlying(_amount);
require(returnCode == 0, "Error when redeeming"); // 0 on success: https://docs.compound.finance/v2/ctokens/#redeem
IERC20 note = IERC20(CErc20Interface(cNote).underlying());
SafeERC20.safeTransfer(note, msg.sender, _amount);
emit CarryWithdrawal(_amount);
}
- With
balanceOfUnderlying(Recommended): Utilize thebalanceOfUnderlyingfunction for a simpler calculation ofmaximumWithdrawable.
function withdrawCarry(uint256 _amount) external onlyOwner {
// 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).balanceOfUnderlying(address(this)) - totalSupply();
if (_amount == 0) {
_amount = maximumWithdrawable;
} else {
require(_amount <= maximumWithdrawable, "Too many tokens requested");
}
// Technically, _amount can still be 0 at this point, which would make the following two calls unnecessary.
// But we do not handle this case specifically, as the only consequence is that the owner wastes a bit of gas when there is nothing to withdraw
uint256 returnCode = CErc20Interface(cNote).redeemUnderlying(_amount);
require(returnCode == 0, "Error when redeeming"); // 0 on success: https://docs.compound.finance/v2/ctokens/#redeem
IERC20 note = IERC20(CErc20Interface(cNote).underlying());
SafeERC20.safeTransfer(note, msg.sender, _amount);
emit CarryWithdrawal(_amount);
}
The second option is highly recommended for its accuracy and simplicity.
Modification of Related Test Codes
Post-modifications to the main contract code, it's essential to update the associated test cases. In the MockCNOTE test contract, all scaling is currently set to 10^28. To align with the main contract changes, the following modifications are recommended for MockCNOTE:
contract MockCNOTE is MockERC20 {
address public underlying;
uint256 public constant SCALE = 1e18;
uint256 public exchangeRateCurrent = SCALE;
constructor(string memory symbol, string memory name, address _underlying) MockERC20(symbol, name) {
underlying = _underlying;
}
function mint(uint256 amount) public returns (uint256 statusCode) {
SafeERC20.safeTransferFrom(IERC20(underlying), msg.sender, address(this), amount);
_mint(msg.sender, (amount * SCALE) / exchangeRateCurrent);
statusCode = 0;
}
function redeemUnderlying(uint256 amount) public returns (uint256 statusCode) {
SafeERC20.safeTransfer(IERC20(underlying), msg.sender, amount);
_burn(msg.sender, (amount * exchangeRateCurrent) / SCALE);
statusCode = 0;
}
function redeem(uint256 amount) public returns (uint256 statusCode) {
SafeERC20.safeTransfer(IERC20(underlying), msg.sender, (amount * exchangeRateCurrent) / SCALE);
_burn(msg.sender, amount);
statusCode = 0;
}
function setExchangeRate(uint256 _exchangeRate) public {
exchangeRateCurrent = _exchangeRate;
}
}
This revised MockCNOTE contract reflects the updated scale factor and aligns with the main contract's logic.
Scenario Testing with Mainnet Forking
For comprehensive validation, scenario testing using a fork of the mainnet is highly recommended. This approach allows for real-world testing conditions by simulating interactions with existing contracts on the mainnet. It provides a robust environment to verify the correctness and reliability of the contract modifications in real-world scenarios, ensuring that the contract behaves as expected when interfacing with other mainnet contracts.
This step is crucial to identify potential issues that might not be apparent in isolated or simulated environments, enhancing the overall reliability of the contract before deployment.
Assessed type
Math
