Summary
The RToken::mint/burn function contains several critical calculation and logic errors that violate the intended token minting&burning mechanism.
Incorrect Mint Amount:the param amountToMint is the amount of underlying asset to mint,but the function uses it as the amount of RToken to mint more than expected.
Incorrect Burn Amount:the param amount is the amount of underlying asset to burn,but the function uses it as the amount of RToken to burn more than expected.
Wrong Balance Comparison:the RToken::burn function compares the unscaled amount with userBalance,but should compare the scaled amountScaled with userBalance.
Incorrect Token Scaling:the function uses rayMul instead of rayDiv for scaling amounts.
Incorrect Return Values:the RToken::mint/burn function returns the wrong values for scaled amount and underlying amount.
Redundant update:the RToken::burn function updates the _userState[from].index twice.
Redundant content in RToken::mint function
All of the above errors will lead to serious vulnerabilities and require a refactor of these two functions.
Vulnerability Details
function mint(
address caller,
address onBehalfOf,
uint256 amountToMint,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256, uint256) {
if (amountToMint == 0) {
return (false, 0, 0, 0);
}
uint256 amountScaled = amountToMint.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
_mint(onBehalfOf, amountToMint.toUint128());
emit Mint(caller, onBehalfOf, amountToMint, index);
return (isFirstMint, amountToMint, totalSupply(), amountScaled);
}
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
if (amount == 0) {
return (0, totalSupply(), 0);
}
uint256 userBalance = balanceOf(from);
_userState[from].index = index.toUint128();
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayMul(index);
_userState[from].index = index.toUint128();
_burn(from, amount.toUint128());
if (receiverOfUnderlying != address(this)) {
IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
emit Burn(from, receiverOfUnderlying, amount, index);
return (amount, totalSupply(), amount);
}
Impact
Incorrectly mints more rTokens for the user, causing issues for other contracts that depend on this rToken.
More rTokens will be burned compared to expectations.
Due to logical and calculation errors, other potential issues arise.
PoC
Since this is a large project that requires many setup steps in the early stages, and RToken::mint/burn has few external dependencies, the contract has been simplified and refactored, and tested using Foundry.
For instructions on how to use Foundry in Hardhat, you can refer to:
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "../../libraries/math/WadRayMath.sol";
interface IRTokenModified {
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external returns (uint256, uint256, uint256);
function mint(
address caller,
address onBehalfOf,
uint256 amount,
uint256 index
) external returns (bool, uint256, uint256, uint256);
}
contract RTokenModified is ERC20, IRTokenModified {
using WadRayMath for uint256;
using SafeERC20 for IERC20;
using SafeCast for uint256;
address private _assetAddress;
struct UserState {
uint128 index;
}
mapping(address => UserState) private _userState;
error InvalidAmount();
event Burn(address indexed from, address indexed receiverOfUnderlying, uint256 amount, uint256 index);
event Mint(address indexed caller, address indexed onBehalfOf, uint256 amount, uint256 index);
constructor(address asset) ERC20("RToken", "RT") {
_assetAddress = asset;
}
* @notice Compared with the original `RToken::mint`, only removed the `onlyReservePool` modifier
*/
function mint(
address caller,
address onBehalfOf,
uint256 amountToMint,
uint256 index
) external override returns (bool, uint256, uint256, uint256) {
if (amountToMint == 0) {
return (false, 0, 0, 0);
}
uint256 amountScaled = amountToMint.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
_mint(onBehalfOf, amountToMint.toUint128());
emit Mint(caller, onBehalfOf, amountToMint, index);
return (isFirstMint, amountToMint, totalSupply(), amountScaled);
}
* @notice Compared with the original `RToken::burn`, only removed the `onlyReservePool` modifier
*/
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override returns (uint256, uint256, uint256) {
if (amount == 0) {
return (0, totalSupply(), 0);
}
uint256 userBalance = balanceOf(from);
_userState[from].index = index.toUint128();
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayMul(index);
_userState[from].index = index.toUint128();
_burn(from, amount.toUint128());
if (receiverOfUnderlying != address(this)) {
IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
emit Burn(from, receiverOfUnderlying, amount, index);
return (amount, totalSupply(), amount);
}
}
pragma solidity ^0.8.19;
import {Test , console2} from "forge-std/Test.sol";
import {RTokenModified} from "../contracts/core/tokens/RTokenModified.sol";
import {ERC20Mock} from "../contracts/mocks/core/tokens/ERC20Mock.sol";
import {WadRayMath} from "../contracts/libraries/math/WadRayMath.sol";
contract RTokenModifiedTest is Test {
using WadRayMath for uint256;
uint256 constant AMOUNT_TO_MINT = 10 ether;
RTokenModified rTokenModified;
ERC20Mock asset;
address USER;
uint256 index;
uint256 newIndex;
function setUp() public {
asset = new ERC20Mock("Asset", "ASSET");
USER = makeAddr("USER");
rTokenModified = new RTokenModified(address(asset));
index = 2e27;
}
function test_mint_more_rtoken_than_expected() public {
uint256 expectedRTokenBalance = AMOUNT_TO_MINT.rayDiv(index);
vm.prank(USER);
(,uint256 returnedRTokenBalance,,)=rTokenModified.mint(USER, USER, AMOUNT_TO_MINT, index);
uint256 actualRTokenBalance=rTokenModified.balanceOf(USER);
console2.log("expected RToken balance is:",expectedRTokenBalance);
console2.log("returned RToken balance is:",returnedRTokenBalance);
console2.log("actual RToken balance after mint is:",actualRTokenBalance);
}
function test_burn_more_rtoken_than_expected() public {
vm.prank(USER);
rTokenModified.mint(USER, USER, AMOUNT_TO_MINT, index);
asset.mint(address(rTokenModified),10000000 ether);
uint256 expectedAssertBalance=AMOUNT_TO_MINT/2;
console2.log("expected assert balance after burn is:",expectedAssertBalance);
uint256 rTokenBalanceBeforeBurn=rTokenModified.balanceOf(USER);
vm.prank(USER);
rTokenModified.burn(USER, USER, AMOUNT_TO_MINT/2, index);
uint256 assertBalanceAfterBurn=asset.balanceOf(USER);
console2.log("actual assert balance after burn is:",assertBalanceAfterBurn);
uint256 expectedrTokenBalanceAfterBurn=rTokenBalanceBeforeBurn-AMOUNT_TO_MINT.rayDiv(index)/2;
console2.log("expected rToken balance after burn is:",expectedrTokenBalanceAfterBurn);
uint256 actualrTokenBalanceAfterBurn=rTokenModified.balanceOf(USER);
console2.log("actual rToken balance after burn is:",actualrTokenBalanceAfterBurn);
}
}
forge test --mt test_mint_more_rtoken_than_expected -vv
forge test --mt test_burn_more_rtoken_than_expected -vv
Ran 1 test for test/RTokenTest.t.sol:RTokenModifiedTest
[PASS] test_mint_more_rtoken_than_expected() (gas: 95337)
Logs:
expected RToken balance is: 5000000000000000000
returned RToken balance is: 10000000000000000000
actual RToken balance after mint is: 10000000000000000000
Ran 1 test for test/RTokenTest.t.sol:RTokenModifiedTest
[PASS] test_burn_more_rtoken_than_expected() (gas: 193958)
Logs:
expected assert balance after burn is: 5000000000000000000
actual assert balance after burn is: 5000000000000000000
expected rToken balance after burn is: 7500000000000000000
actual rToken balance after burn is: 5000000000000000000
Recommendations
Refactor as follows:
function mint(
address caller,
address onBehalfOf,
uint256 amountToMint,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256, uint256) {
if (amountToMint == 0) {
return (false, 0, 0, 0);
}
uint256 amountScaled = amountToMint.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
- uint256 balanceIncrease = 0;
- if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
- balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
- }
_userState[onBehalfOf].index = index.toUint128();
- _mint(onBehalfOf, amountToMint.toUint128());
+ _mint(onBehalfOf, amountScaled.toUint128());
emit Mint(caller, onBehalfOf, amountToMint, index);
- return (isFirstMint, amountToMint, totalSupply(), amountScaled);
+ return (isFirstMint, amountScaled, totalSupply(), amountToMint);
}
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
if (amount == 0) return (0, totalSupply(), 0);
uint256 userBalance = balanceOf(from);
- _userState[from].index = index.toUint128();
- if(amount > userBalance){
- amount = userBalance;
- }
- uint256 amountScaled = amount.rayMul(index);
+ uint256 amountScaled = amount.rayDiv(index);
+ if(amountScaled > userBalance) {
+ amountScaled = userBalance;
+ }
_userState[from].index = index.toUint128();
- _burn(from, amount.toUint128());
+ _burn(from, amountScaled.toUint128());
+ uint256 underlyingAmount = amountScaled.rayMul(index);
if (receiverOfUnderlying != address(this)) {
- IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
+ IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, underlyingAmount);
}
- return (amount, totalSupply(), amount);
+ return (amountScaled, totalSupply(), underlyingAmount);
}