Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: medium
Valid

ERC20 Balances is not equal to Voting Power

Summary

Voting power decays linearly over time, but balanceOf(user) only updates during explicit interactions (e.g., extend(), withdraw()).

After a lock expires, users retain their full veRAAC balance until they call withdraw(), even though their voting power is zero.

A user with a 1-year lock will see their balanceOf() remain static while their actual voting power decays daily. This breaks governance and boost calculations.

Vulnerability Details

/**
* @notice Creates a new lock position for RAAC tokens
* @dev Locks RAAC tokens for a specified duration and mints veRAAC tokens representing voting power
* @param amount The amount of RAAC tokens to lock
* @param duration The duration to lock tokens for, in seconds
*/
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));
_checkpointState.writeCheckpoint(msg.sender, newPower);
// Mint veTokens
_mint(msg.sender, newPower);
emit LockCreated(msg.sender, amount, unlockTime);
}

Alice locks 100 RAAC for 4 years, then lets her lock expire without interacting further.

Expected Behavior:

Alice's veRAAC balance should decay to 0 linearly over 4 years.

After 2 years, her balance should be 50 veRAAC.

Actual Behavior:

Alice's veRAAC balance remains 100 until she calls withdraw(), even though her voting power decays.

After 2 Years

Expected Voting Power:
100 * (remaining time) / total lock duration = 100 * 2y / 4y = 50 veRAAC.

Actual State:

balanceOf(alice) remains 100 (no automatic decay).

getVotingPower(alice) returns 50 (correctly calculated via _votingState).

She calls recordVote() for a proposal.

function recordVote(address voter, uint256 proposalId) external {
uint256 power = getVotingPower(voter); // 50 (correct)
emit VoteCast(voter, proposalId, power);
}

Voting Power Used: 50 (correct).

But: Her veRAAC balance still shows 100 (balanceOf(alice) = 100).

A rewards contract calls calculateBoost(alice, 100 RAAC).

function calculateBoost(address user, uint256 amount) external view
returns (uint256 boostBasisPoints, uint256 boostedAmount)
{
return _boostState.calculateTimeWeightedBoost(
balanceOf(user), // 100 (incorrectly used!)
totalSupply(),
amount
);
}

Boost is calculated using 100 veRAAC instead of the true 50, giving Alice an unfair advantage.

Lock Expires (4 Years Later)

Alice does not call withdraw().

State:

balanceOf(alice) = 100 (still!).

getVotingPower(alice) = 0 (correct).

Impact

balanceOf() is used externally to display voting power (e.g., in UIs), misleading users. Rewards are overpaid because boosts use stale ERC20 balances. Expired locks still contribute to totalSupply(), distorting system-wide metrics.

Tools Used

Foundry

Recommendations

Implement a _beforeTokenTransfer hook that decays balanceOf(user) to match their real-time voting power on every interaction (e.g., transfers, votes).

Ensure balanceOf(user) always reflects getVotingPower(user) by updating balances during state changes (e.g., block timestamp checks).

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

BaseGauge::_applyBoost, GaugeController::vote, BoostController::calculateBoost use balanceOf() instead of getVotingPower() for vote-escrow tokens, negating time-decay mechanism

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!