Core Contracts

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

Phantom Votes Break RAAC's Gauge Democracy

Summary

The voting power tracking in GaugeController fails to properly update user votes when weights change. This compromises the integrity of the gauge voting system and could lead to incorrect reward distributions. The issue occurs in the vote() function where a user's voting power is not being properly validated against their current veToken balance. When a user votes for a gauge weight, their vote gets recorded but the contract fails to ensure the voting power remains consistent with their token holdings.

When we verifies that user votes are properly tracked here:

// Initial state
userGaugeVotes[user][gauge] = oldWeight
// After vote() call
userGaugeVotes[user][gauge] = newWeight

Here's how this plays out

// Initial setup
veToken.balanceOf(alice) = 1000e18 // Alice locks 1000 RAAC
userGaugeVotes[alice][rwaPriceGauge] = 0
// Alice votes with full power
vote(rwaPriceGauge, 1000e18) ✅ // Looks good so far...
// Later: Alice's lock expires but...
veToken.balanceOf(alice) = 0
userGaugeVotes[alice][rwaPriceGauge] = 1000e18 // 🚩 Vote remains

The issue is that the contract records the new vote weight without checking if the user still has sufficient voting power to support that weight.

This could allow users to maintain voting influence even after their veToken balance has decreased. In a real scenario, this is like being able to keep voting rights after selling your shares.

The code assumed veToken balances would only decrease through time-decay or explicit unlocking. However, the veToken contract allows transfers in certain conditions, creating a disconnect between voting power and vote weight.

This built on Curve's gauge voting system but missed a key difference. RAAC's veToken has more flexible transfer conditions than veCRV. This creates an assumption gap similar to the Compound governance attack of 2021, but with gauge-specific implications.

Looking at the GaugeController acts as the democratic heart of RAAC's real estate price oracle system. When users lock RAAC tokens in the veRAACToken contract, they gain voting power to influence gauge weights, which directly impact real estate valuations and lending parameters.

The core issue emerges in the vote() function's interaction with veToken balances. Think of a voter who locks 1000 RAAC tokens to gain influence over the RWA gauge. They cast their vote, directing price oracle weights. However, even after their lock expires and their veToken balance drops to zero, their vote continues to shape protocol decisions. This creates a dangerous disconnect between actual stake and voting power.

Looking at the technical implementation, the GaugeController tracks votes through userGaugeVotes[user][gauge], but crucially fails to validate this against current veToken.getVotingPower(user). This allows "ghost votes" to persist, potentially directing millions in real estate valuations without any actual stake at risk.

Vulnerability Details

The vote() function from GaugeController.sol.

function vote(address gauge, uint256 weight) external override whenNotPaused {
if (!isGauge(gauge)) revert GaugeNotFound(); // 🔍 Gauge validation
if (weight > WEIGHT_PRECISION) revert InvalidWeight(); // ⚖️ Weight bounds check
uint256 votingPower = veRAACToken.balanceOf(msg.sender); // 🎭 Current voting power
if (votingPower == 0) revert NoVotingPower(); // ⛔ Zero power check
uint256 oldWeight = userGaugeVotes[msg.sender][gauge];
userGaugeVotes[msg.sender][gauge] = weight; // 🚩 Critical: Updates weight without power validation
_updateGaugeWeight(gauge, oldWeight, weight, votingPower); // 📊 Updates global weights
emit WeightUpdated(gauge, oldWeight, weight); // 📢 Event emission
}

The fact that while it checks current votingPower > 0, it doesn't validate that the new weight is <= votingPower, allowing votes to potentially exceed actual voting power.

Imagine a real estate voting system where property valuations are determined by stakeholder influence. The _updateGaugeWeight function acts as the vote tallying mechanism, calculating how much influence each voter has over property prices based on their locked tokens.

The current implementation has a flaw in how it handles vote weight calculations. When a user changes their vote, the function recalculates the gauge's total weight using this formula:

newGaugeWeight = oldGaugeWeight
- (oldWeight * votingPower / WEIGHT_PRECISION) // Remove previous vote
+ (newWeight * votingPower / WEIGHT_PRECISION) // Add new vote

Think of this like a ballot box where votes aren't properly validated. A malicious voter could cast votes with artificially inflated votingPower, causing the newGaugeWeight to exceed realistic bounds. This directly impacts RAAC's real estate price oracle system, as gauge weights determine how much influence different price feeds have.

Impact

An attacker could manipulate property valuations by inflating their gauge weight, potentially affecting millions in collateralized loans. This isn't just about numbers, it's about the integrity of real estate price discovery in the protocol.

Recommendations

In the _updateGaugeWeight function where the weight delta calculation occurs.

function _updateGaugeWeight(
address gauge,
uint256 oldWeight,
uint256 newWeight,
uint256 votingPower
) internal {
Gauge storage g = gauges[gauge];
// 🚩 Critical: No validation that (newWeight * votingPower) stays within bounds
uint256 oldGaugeWeight = g.weight;
uint256 newGaugeWeight = oldGaugeWeight
- (oldWeight * votingPower / WEIGHT_PRECISION) // ⬇️ Remove old influence
+ (newWeight * votingPower / WEIGHT_PRECISION); // ⬆️ Add new influence
g.weight = newGaugeWeight; // 💫 Updates global gauge weight
g.lastUpdateTime = block.timestamp; // ⏰ Update timestamp
}

Should be:

function _updateGaugeWeight(
address gauge,
uint256 oldWeight,
uint256 newWeight,
uint256 votingPower
) internal {
Gauge storage g = gauges[gauge];
// ✅ Validate weight delta doesn't overflow
uint256 weightDelta = (newWeight * votingPower) / WEIGHT_PRECISION;
require(weightDelta <= type(uint256).max - g.weight, "Weight overflow");
uint256 oldGaugeWeight = g.weight;
uint256 newGaugeWeight = oldGaugeWeight
- (oldWeight * votingPower / WEIGHT_PRECISION)
+ weightDelta;
g.weight = newGaugeWeight;
g.lastUpdateTime = block.timestamp;
}

This ensures the gauge's total weight stays within safe bounds when aggregating voting power influence.

Updates

Lead Judging Commences

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

GaugeController::_updateGaugeWeight uses current voting power for both old and new vote calculations, causing underflows when voting power increases and incorrect gauge weights

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

GaugeController::_updateGaugeWeight uses current voting power for both old and new vote calculations, causing underflows when voting power increases and incorrect gauge weights

Support

FAQs

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

Give us feedback!