Summary
In the veRAACToken
contract, there is an external, non-reentrant, and pausable function called lock
. This function locks raacTokens
and issues a proportional amount of veRaacTokens
. It enforces several restrictions: the locking amount must not be zero, it must not exceed the MAX_LOCK_AMOUNT
constant (10M tokens with 18 decimal precision), the lock duration must fall within the allowed range (MIN_LOCK_DURATION
≤ duration ≤ MAX_LOCK_DURATION
), and the total supply of veRaacTokens
must not exceed the MAX_TOTAL_SUPPLY
constant (100M tokens with 18 decimal precision).
However, there is a flaw in the total supply restriction check. The totalSupply()
function returns the total number of veRaacTokens
in circulation, while the amount
parameter represents the quantity of raacTokens
that are about to be locked. Because these values refer to different tokens—totalSupply()
for veRaacTokens
and amount
for raacTokens
—this discrepancy can lead to a Denial of Service (DoS) scenario. As the total supply of veRaacTokens
nears its maximum, the protocol may be unable to issue the full 100M veRaacTokens
. This, in turn, can adversely affect the quorum in the Governance contract and stifle potential competition by preventing further issuance of veRaacTokens
.
Vulnerability Details
veRAACToken::lock:
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();
}
raacToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 unlockTime = block.timestamp + duration;
_lockState.createLock(msg.sender, amount, duration);
_updateBoostState(msg.sender, amount);
(int128 bias, int128 slope) = _votingState.calculateAndUpdatePower(msg.sender, amount, unlockTime);
uint256 newPower = uint256(uint128(bias));
_checkpointState.writeCheckpoint(msg.sender, newPower);
_mint(msg.sender, newPower);
emit LockCreated(msg.sender, amount, unlockTime);
}
Governance::quorum:
function quorum() public view override returns (uint256) {
return (_veToken.getTotalVotingPower() * quorumNumerator) / QUORUM_DENOMINATOR;
}
Proof of Concept
To demonstrate this vulnerability, the following Proof of Concept (PoC) is provided. The PoC is written using the Foundry tool.
-
Step 1: Create a Foundry project and place all the contracts in the src
directory.
-
Step 2: Create a test
directory and a mocks
folder within the src
directory (or use an existing mocks folder).
-
Step 3: Create all necessary mock contracts, if required.
-
Step 4: Create a test file (with any name) in the test
directory.
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {veRAACToken} from "../src/core/tokens/veRAACToken.sol";
import {RAACToken} from "../src/core/tokens/RAACToken.sol";
import {TimeWeightedAverage} from "../src/libraries/math/TimeWeightedAverage.sol";
import {LockManager} from "../src/libraries/governance/LockManager.sol";
import {IveRAACToken} from "../src/interfaces/core/tokens/IveRAACToken.sol";
contract VeRAACTokenTest is Test {
veRAACToken veRaacToken;
RAACToken raacToken;
address RAAC_OWNER = makeAddr("RAAC_OWNER");
address RAAC_MINTER = makeAddr("RAAC_MINTER");
uint256 initialRaacSwapTaxRateInBps = 200;
uint256 initialRaacBurnTaxRateInBps = 150;
address VE_RAAC_OWNER = makeAddr("VE_RAAC_OWNER");
address ALICE = makeAddr("ALICE");
address BOB = makeAddr("BOB");
address CHARLIE = makeAddr("CHARLIE");
address DEVIL = makeAddr("DEVIL");
function setUp() public {
raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
vm.startPrank(VE_RAAC_OWNER);
veRaacToken = new veRAACToken(address(raacToken));
vm.stopPrank();
}
}
Step 5: Add the following test PoC in the test file, after the setUp
function.
function testLockAmountVeRaacTokenTotalSupplyWrongComparison() public {
uint256 LOCK_AMOUNT = 10_000_000e18;
uint256 LOCK_DURATION = 365 days;
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(RAAC_MINTER);
vm.stopPrank();
for (uint256 i = 1; i <= 36; i++) {
vm.startPrank(RAAC_MINTER);
raacToken.mint(address(uint160(i)), LOCK_AMOUNT);
vm.stopPrank();
vm.startPrank(address(uint160(i)));
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
}
console.log("total supply max: ", veRaacToken.MAX_TOTAL_SUPPLY());
console.log("total supply : ", veRaacToken.getTotalVotingPower());
vm.startPrank(RAAC_MINTER);
raacToken.mint(ALICE, LOCK_AMOUNT);
vm.stopPrank();
vm.startPrank(ALICE);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
console.log("after adding last 10M");
console.log("total supply max: ", veRaacToken.MAX_TOTAL_SUPPLY());
console.log("total supply : ", veRaacToken.getTotalVotingPower());
vm.startPrank(RAAC_MINTER);
raacToken.mint(BOB, LOCK_AMOUNT);
vm.stopPrank();
vm.startPrank(BOB);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
vm.expectRevert(bytes4(keccak256("TotalSupplyLimitExceeded()")));
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
uint256 canLockAmount = veRaacToken.MAX_TOTAL_SUPPLY() - veRaacToken.getTotalVotingPower();
vm.startPrank(RAAC_MINTER);
raacToken.mint(BOB, canLockAmount);
vm.stopPrank();
vm.startPrank(BOB);
raacToken.approve(address(veRaacToken), canLockAmount);
veRaacToken.lock(canLockAmount, LOCK_DURATION);
vm.stopPrank();
console.log("after adding last 7.5M");
console.log("total supply max: ", veRaacToken.MAX_TOTAL_SUPPLY());
console.log("total supply : ", veRaacToken.getTotalVotingPower());
}
Step 6: To run the test, execute the following commands in your terminal:
forge test --mt testLockAmountVeRaacTokenTotalSupplyWrongComparison -vv
Step 7: Review the output. The expected output should indicate that there's a Potential DoS due to the diminishing rate of the lockable raac tokens.
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/VeRAACTokenTest.t.sol:VeRAACTokenTest
[PASS] testLockAmountVeRaacTokenTotalSupplyWrongComparison() (gas: 8887711)
Logs:
total supply max: 100000000000000000000000000
total supply : 90000000000000000000000000
after adding last 10M
total supply max: 100000000000000000000000000
total supply : 92500000000000000000000000
after adding last 7.5M
total supply max: 100000000000000000000000000
total supply : 94375000000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 134.26ms (37.78ms CPU time)
Ran 1 test suite in 366.10ms (134.26ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
As demonstrated, the test confirms that a Denial of Service (DoS) is possible overtime.
Impact
Reduces the quorum in governance.
Stifles potential competition by preventing further issuance of veRaacTokens
.
Leads to a gradual Denial of Service (DoS) over time due to the diminishing rate of lockable raacTokens
.
Undermines the protocol's intended functionality.
Tools Used
Manual Review
Foundry
Console Log (foundry)
Recommendations
The raacToken
amount to be locked is first converted into the equivalent veRaacToken
amount, which is then added to the current totalSupply()
. This sum is subsequently compared with the MAX_TOTAL_SUPPLY
.
One possible solution is as follows:
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();
}
+ if (totalSupply() + calculateVeAmount(amount, duration) > MAX_TOTAL_SUPPLY) revert TotalSupplyLimitExceeded();
// 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));
_checkpointState.writeCheckpoint(msg.sender, newPower);
// Mint veTokens
_mint(msg.sender, newPower);
emit LockCreated(msg.sender, amount, unlockTime);
}