Part 2

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

The fees from `receiveMarketFee` are distributed incorrectly. Not all vault stakers will be rewarded

Summary

The received market fee is split between protocol and LPs who stake their shares.
The problem is the market's wethRewardPerVaultShare is incorrectly calculated and the same amount is allocated to all vaults who delegated credit to market.

Vulnerability Details

LP who contributed with liquidity to a vault can stake their vault tokens to receive marketFee in the form of weth.
The weth marketFee is received and processed by receiveMarketFee ->_handleWethRewardDistribution -> market.receiveWethReward

Or it's converted to weth by convertAccumulatedFeesToWeth -> _handleWethRewardDistribution -> receiveWethReward if the fee was paid in a different token than weth.

function receiveWethReward(
Data storage self,
address asset,
UD60x18 receivedProtocolWethRewardX18,
UD60x18 receivedVaultsWethRewardX18
)
internal
{
// if a market credit deposit asset has been used to acquire the received weth, we need to reset its balance
if (asset != address(0)) {
// removes the given asset from the received market fees enumerable map as we assume it's been fully
// swapped to weth
self.receivedFees.remove(asset);
}
// increment the amount of pending weth reward to be distributed to fee recipients
self.availableProtocolWethReward =
ud60x18(self.availableProtocolWethReward).add(receivedProtocolWethRewardX18).intoUint128();
// increment the all time weth reward storage
@> self.wethRewardPerVaultShare =
ud60x18(self.wethRewardPerVaultShare).add(receivedVaultsWethRewardX18).intoUint128();
}

The vault vaultTotalWethRewardChangeX18 is calculated as the sum of all wethRewardChangeX18 returned by market.getVaultAccumulatedValues

function _recalculateConnectedMarketsState(
Data storage self,
uint128[] memory connectedMarketsIdsCache,
bool shouldRehydrateCache
)
private
returns (
uint128[] memory rehydratedConnectedMarketsIdsCache,
SD59x18 vaultTotalRealizedDebtChangeUsdX18,
SD59x18 vaultTotalUnrealizedDebtChangeUsdX18,
UD60x18 vaultTotalUsdcCreditChangeX18,
UD60x18 vaultTotalWethRewardChangeX18
)
{
...
// get the vault's accumulated debt, credit and reward changes from the market to update its stored
// values
(
ctx.realizedDebtChangeUsdX18,
ctx.unrealizedDebtChangeUsdX18,
ctx.usdcCreditChangeX18,
@> ctx.wethRewardChangeX18
@> ) = market.getVaultAccumulatedValues(
ud60x18(creditDelegation.valueUsd),
sd59x18(creditDelegation.lastVaultDistributedRealizedDebtUsdPerShare),
sd59x18(creditDelegation.lastVaultDistributedUnrealizedDebtUsdPerShare),
ud60x18(creditDelegation.lastVaultDistributedUsdcCreditPerShare),
ud60x18(creditDelegation.lastVaultDistributedWethRewardPerShare)
);
...
@> vaultTotalWethRewardChangeX18 = vaultTotalWethRewardChangeX18.add(ctx.wethRewardChangeX18);
...

The returned reward delta, wethRewardChangeX18, is the difference between the rewards stored in market's wethRewardPerVaultShare (updated by receiveWethReward as shown earlier) and the last value stored in creditDelegation.

function getVaultAccumulatedValues(
Data storage self,
UD60x18 vaultDelegatedCreditUsdX18,
SD59x18 lastVaultDistributedRealizedDebtUsdPerShareX18,
SD59x18 lastVaultDistributedUnrealizedDebtUsdPerShareX18,
UD60x18 lastVaultDistributedUsdcCreditPerShareX18,
UD60x18 lastVaultDistributedWethRewardPerShareX18
)
internal
view
returns (
SD59x18 realizedDebtChangeUsdX18,
SD59x18 unrealizedDebtChangeUsdX18,
UD60x18 usdcCreditChangeX18,
UD60x18 wethRewardChangeX18
)
{
@> wethRewardChangeX18 = ud60x18(self.wethRewardPerVaultShare).sub(lastVaultDistributedWethRewardPerShareX18);

After this sum is calculated, the sum,vaultTotalWethRewardChangeX18, is passed to self.wethRewardDistribution.distributeValue to increase the vault's value per share.

The issue is that market's rewards aren't distributed to vaults based on the amount of credit each vault delegated to the market. The Same amount of reward is distributed to all connected vaults. Instead, the fair share of reward should be distributed to each vault.

function receiveWethReward(
Data storage self,
address asset,
UD60x18 receivedProtocolWethRewardX18,
UD60x18 receivedVaultsWethRewardX18
)
internal
{
// if a market credit deposit asset has been used to acquire the received weth, we need to reset its balance
if (asset != address(0)) {
// removes the given asset from the received market fees enumerable map as we assume it's been fully
// swapped to weth
self.receivedFees.remove(asset);
}
// increment the amount of pending weth reward to be distributed to fee recipients
self.availableProtocolWethReward =
ud60x18(self.availableProtocolWethReward).add(receivedProtocolWethRewardX18).intoUint128();
// increment the all time weth reward storage
// @audit received rewards should be divided by `totalDelegatedCreditUsd` to get the 'perShare' amount
@> self.wethRewardPerVaultShare =
ud60x18(self.wethRewardPerVaultShare).add(receivedVaultsWethRewardX18).intoUint128();
}

Impact

Not all users who stakes their vault shares will be rewarded.
If market-making engine has weth from other sources than market fee (eg. if weth is deposited as credit when a trader is liquidated/ settles negative PnL, etc), stakers can withdraw rewards intended for other purposes. LP get less rewards than they should.

Tools Used

Recommendations

Consider the following changes:

function receiveWethReward(
Data storage self,
address asset,
UD60x18 receivedProtocolWethRewardX18,
UD60x18 receivedVaultsWethRewardX18
)
internal
{
...
// increment the all time weth reward storage
- self.wethRewardPerVaultShare =
- ud60x18(self.wethRewardPerVaultShare).add(receivedVaultsWethRewardX18).intoUint128();
+ self.wethRewardPerVaultShare =
+ ud60x18(self.wethRewardPerVaultShare).add(receivedVaultsWethRewardX18.div(getTotalDelegatedCreditUsd(self)).intoUint128();
}
function getVaultAccumulatedValues(
Data storage self,
UD60x18 vaultDelegatedCreditUsdX18,
SD59x18 lastVaultDistributedRealizedDebtUsdPerShareX18,
SD59x18 lastVaultDistributedUnrealizedDebtUsdPerShareX18,
UD60x18 lastVaultDistributedUsdcCreditPerShareX18,
UD60x18 lastVaultDistributedWethRewardPerShareX18
)
internal
view
returns (
SD59x18 realizedDebtChangeUsdX18,
SD59x18 unrealizedDebtChangeUsdX18,
UD60x18 usdcCreditChangeX18,
UD60x18 wethRewardChangeX18
)
{
// calculate the vault's share of the total delegated credit, from 0 to 1
UD60x18 vaultCreditShareX18 = vaultDelegatedCreditUsdX18.div(getTotalDelegatedCreditUsd(self));
// calculate the vault's value changes since its last accumulation
// note: if the last distributed value is zero, we assume it's the first time the vault is accumulating
// values, thus, it needs to return zero changes
realizedDebtChangeUsdX18 = !lastVaultDistributedRealizedDebtUsdPerShareX18.isZero()
? sd59x18(self.realizedDebtUsdPerVaultShare).sub(lastVaultDistributedRealizedDebtUsdPerShareX18).mul(
vaultCreditShareX18.intoSD59x18()
)
: SD59x18_ZERO;
...
// TODO: fix the vaultCreditShareX18 flow to multiply by `wethRewardChangeX18`
- wethRewardChangeX18 = ud60x18(self.wethRewardPerVaultShare).sub(lastVaultDistributedWethRewardPerShareX18);
+ wethRewardChangeX18 = !lastVaultDistributedWethRewardPerShareX18.isZero()
+ ? ud60x18(self.wethRewardPerVaultShare).sub(lastVaultDistributedWethRewardPerShareX18).mul(
+ vaultCreditShareX18
+ )
+ : UD60x18_ZERO;
}
Updates

Lead Judging Commences

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

`wethRewardPerVaultShare` is incremented by `receivedVaultWethReward` amount which is not divided by number of shares.

Support

FAQs

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