Description
The lock function in veRAACToken contains an incorrect check for MAX_TOTAL_SUPPLY. Currently, the function compares totalSupply() + amount against MAX_TOTAL_SUPPLY, which is incorrect because the amount of veRAAC tokens minted depends on the lock duration.
This flawed check can unnecessarily revert valid transactions, preventing users from locking their RAAC tokens even when the actual veRAAC mint amount does not exceed MAX_TOTAL_SUPPLY.
For example:
Assume MAX_TOTAL_SUPPLY = 100_000_000e18
Assume totalSupply = 99_999_500e18
Bob tries to lock 1_000e18 for 2 years
The function incorrectly reverts, even though the actual mint amount is only 500e18, which should be allowed
Context
Impact
Low. This issue does not compromise security but could disrupt locking functionality.
Likelihood
Medium. The incorrect check is always present, but it only affects users in specific scenarios (i.e., when totalSupply() is close to MAX_TOTAL_SUPPLY).
Proof of Concept
pragma solidity ^0.8.19;
import {Test, console} from "../../lib/forge-std/src/Test.sol";
import {Governance, IGovernance} from "../../contracts/core/governance/proposals/Governance.sol";
import {TimelockController} from "../../contracts/core/governance/proposals/TimelockController.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {veRAACToken, IveRAACToken} from "../../contracts/core/tokens/veRAACToken.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
contract GovernanceTest is Test {
Governance governance;
TimelockController timelock;
RAACToken raac;
veRAACToken veRaac;
address OWNER = makeAddr("owner");
address PROPOSER = makeAddr("proposer");
address EXECUTOR = makeAddr("executor");
address USER = makeAddr("user");
function setUp() public {
vm.startPrank(OWNER);
address[] memory proposers = new address[](1);
proposers[0] = PROPOSER;
address[] memory executors = new address[](1);
executors[0] = EXECUTOR;
timelock = new TimelockController(2 days, proposers, executors, OWNER);
raac = new RAACToken(OWNER, 0, 0);
veRaac = new veRAACToken(address(raac));
governance = new Governance(address(veRaac), address(timelock));
timelock.grantRole(timelock.PROPOSER_ROLE(), address(governance));
timelock.grantRole(timelock.EXECUTOR_ROLE(), address(governance));
timelock.grantRole(timelock.CANCELLER_ROLE(), address(governance));
vm.stopPrank();
}
function test_wrongMaxTotalSupplyCheck_lock() public {
uint256 lockAmount;
for (uint256 i; i < 10; ++i) {
address user = makeAddr(string.concat("user", Strings.toString(i)));
lockAmount = i == 9 ? 9_999_500e18 : 10_000_000e18;
deal(address(raac), user, lockAmount);
vm.startPrank(user);
raac.approve(address(veRaac), lockAmount);
veRaac.lock(lockAmount, veRaac.MAX_LOCK_DURATION());
vm.stopPrank();
}
console.log(veRaac.totalSupply());
lockAmount = 1_000e18;
uint256 lockDuration = veRaac.MAX_LOCK_DURATION() / 2;
deal(address(raac), USER, lockAmount);
vm.startPrank(USER);
raac.approve(address(veRaac), lockAmount);
vm.expectRevert(IveRAACToken.TotalSupplyLimitExceeded.selector);
veRaac.lock(lockAmount, lockDuration);
vm.stopPrank();
uint256 expectedVeRaacs = veRaac.calculateVeAmount(lockAmount, lockDuration);
assertFalse(veRaac.totalSupply() + expectedVeRaacs > 100_000_000e18);
}
}
Instructions
First, integrate Foundry by running the following commands in your terminal, in the project's root directory:
mkdir out lib
git submodule add https://github.com/foundry-rs/forge-std lib/forge-std
touch foundry.toml
Next, configure Foundry by adding the following settings to foundry.toml:
[profile.default]
src = "contracts"
out = "out"
lib = "lib"
After that, create a foundry/ directory inside the test/ directory. Inside foundry/, create the following files:
Finally, paste the provided (PoC) into GovernanceTest.t.sol and run:
forge test --mt test_wrongMaxTotalSupplyCheck_lock -vvv
Recommendation
Fix the MAX_TOTAL_SUPPLY check in the lock function by using newPower instead of amount.
function lock(uint256 amount, uint256 duration) external nonReentrant whenNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount > MAX_LOCK_AMOUNT) revert AmountExceedsLimit();
- if (totalSupply() + amount > MAX_TOTAL_SUPPLY) revert TotalSupplyLimitExceeded();
if (duration < MIN_LOCK_DURATION || duration > MAX_LOCK_DURATION)
revert InvalidLockDuration();
// Do the transfer first - this will revert with ERC20InsufficientBalance if user doesn't have enough tokens
raacToken.safeTransferFrom(msg.sender, address(this), amount);
// Calculate unlock time
uint256 unlockTime = block.timestamp + duration;
// Create lock position
_lockState.createLock(msg.sender, amount, duration);
_updateBoostState(msg.sender, amount);
// Calculate initial voting power
(int128 bias, int128 slope) = _votingState.calculateAndUpdatePower(
msg.sender,
amount,
unlockTime
);
// Update checkpoints
uint256 newPower = uint256(uint128(bias));
+ if (totalSupply() + newPower > MAX_TOTAL_SUPPLY) revert TotalSupplyLimitExceeded();
_checkpointState.writeCheckpoint(msg.sender, newPower);
// Mint veTokens
_mint(msg.sender, newPower);
emit LockCreated(msg.sender, amount, unlockTime);
}