Part 2

Zaros
PerpetualsDEXFoundrySolidity
70,000 USDC
View results
Submission Details
Severity: high
Invalid

Stale State in Fee Distribution Allows Incorrect WETH Claims

Summary

The fee claiming mechanism uses outdated protocol accounting data due to missing state synchronization, enabling users to claim incorrect WETH amounts. The FeeDistributionBranch.claimFees function fails to update critical distribution parameters before calculating rewards, allowing claims based on stale valuePerShare values. This violates the protocol's financial integrity guarantees and creates systemic accounting errors.

Vulnerability Details

The fee claiming mechanism contains a critical state synchronization flaw where fee calculations use stale protocol accounting data. The FeeDistributionBranch.claimFees function (FeeDistributionBranch.sol#L295) relies on distribution values that are only updated through Vault.recalculateVaultsCreditCapacity, but fails to ensure this state update occurs before fee calculations:

// FeeDistributionBranch.claimFees()
function claimFees(uint128 vaultId) external {
// ...
// get the claimable amount of fees
@> UD60x18 amountToClaimX18 = vault.wethRewardDistribution.getActorValueChange(actorId).intoUD60x18();
// reverts if the claimable amount is 0
if (amountToClaimX18.isZero()) revert Errors.NoFeesToClaim();
// ...
// convert the amount to claim to weth amount
uint256 amountToClaim = wethCollateral.convertUd60x18ToTokenAmount(amountToClaimX18);
// transfer the amount to the claimer
IERC20(weth).safeTransfer(msg.sender, amountToClaim);
// ...
}
// Distribution.getValuePerShare()
function getValuePerShare(Data storage self) internal view returns (SD59x18) {
return sd59x18(self.valuePerShare);
}
function _getActorValueChange(
Data storage self,
Actor storage actor
)
private
view
returns (SD59x18 valueChange)
{
@> SD59x18 deltaValuePerShare = sd59x18(self.valuePerShare).sub(sd59x18(actor.lastValuePerShare));
valueChange = deltaValuePerShare.mul(ud60x18(actor.shares).intoSD59x18());
}

the storage state valuePerShare can be only updated in the function Distribution.distributeValue:

// Distribution.distributeValue()
function distributeValue(Data storage self, SD59x18 value) internal {
if (value.eq(SD59x18_ZERO)) {
return;
}
UD60x18 totalShares = ud60x18(self.totalShares);
if (totalShares.eq(UD60x18_ZERO)) {
revert Errors.EmptyDistribution();
}
SD59x18 deltaValuePerShare = value.div(totalShares.intoSD59x18());
@> self.valuePerShare = sd59x18(self.valuePerShare).add(deltaValuePerShare).intoInt256();
}

while this function can only be called by Vault.recalculateVaultsCreditCapacity:

// Vault.recalculateVaultsCreditCapacity()
// iterate over each connected market id and distribute its debt so we can have the latest credit
// delegation of the vault id being iterated to the provided `marketId`
(
uint128[] memory updatedConnectedMarketsIdsCache,
SD59x18 vaultTotalRealizedDebtChangeUsdX18,
SD59x18 vaultTotalUnrealizedDebtChangeUsdX18,
UD60x18 vaultTotalUsdcCreditChangeX18,
UD60x18 vaultTotalWethRewardChangeX18
) = _recalculateConnectedMarketsState(self, connectedMarketsIdsCache, true);
// ...
// distributes the vault's total WETH reward change, earned from its connected markets
if (!vaultTotalWethRewardChangeX18.isZero() && self.wethRewardDistribution.totalShares != 0) {
SD59x18 vaultTotalWethRewardChangeSD59X18 =
sd59x18(int256(vaultTotalWethRewardChangeX18.intoUint256()));
@> self.wethRewardDistribution.distributeValue(vaultTotalWethRewardChangeSD59X18);
}

This creates a race condition where:

  1. Protocol keepers/automation must call Vault.recalculateVaultsCreditCapacity externally

  2. Users can claim fees against outdated valuePerShare values from the Distribution library

  3. Fee transfers use incorrect WETH conversion rates until manual state updates

The core issue stems from the separation of state updates (in vault recalculation) from value claims without enforcing temporal coupling. This violates the invariant that financial calculations must always operate on fresh state data.

Impact

This vulnerability directly impacts the protocol's financial integrity and user trust in three critical ways:

  1. Inaccurate Fee Distribution
    Users may receive incorrect WETH amounts (overpaid or underpaid) based on stale accounting data, violating core protocol guarantees

  2. Protocol Insolvency Risk
    Persistent state desynchronization could create cumulative accounting errors leading to vault undercollateralization

  3. Operational Dependency
    Relies on perfect keeper/automation performance for state updates, introducing systemic fragility

The severity is high as it affects core protocol functionality (fee distribution) and creates direct financial risks for both users and the protocol treasury. Without state synchronization enforcement, the system cannot guarantee proper financial accounting between deposits, fees, and withdrawals.

Tools Used

Manual Review

Recommendations

Modify claimFees to call Vault.recalculateVaultsCreditCapacity before calculating claimable amounts to ensure fresh state data:

function claimFees(uint128 vaultId) external {
Vault.Data storage vault = Vault.loadExisting(vaultId);
// Enforce state update before calculations
Vault.recalculateVaultsCreditCapacity(vaultId);
// Existing implementation continues...
Distribution.Actor memory actor = vault.wethRewardDistribution.getActor(actorId);
UD60x18 amountToClaimX18 = vault.wethRewardDistribution.getActorValueChange(actorId).intoUD60x18();
// ... rest of original implementation
}

The recalculation call ensures valuePerShare reflects latest vault state before computing claimable amounts, eliminating stale data risks.

Updates

Lead Judging Commences

inallhonesty Lead Judge
4 months ago
inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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