Part 2

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

User may not be able to claim staking rewards when multiple vaults are connected to the same market

Summary

User may not be able to claim staking rewards when multiple vaults are connected to the same market.

Vulnerability Details

When Weth rewards are received by a market, the market's wethRewardPerVaultShare is updated.

Market::receiveWethReward():

// increment the all time weth reward storage
self.wethRewardPerVaultShare =
ud60x18(self.wethRewardPerVaultShare).add(receivedVaultsWethRewardX18).intoUint128();

Later when recalculateVaultsCreditCapacity() is called, the reward changes are calculated.

Vault::_recalculateConnectedMarketsState():

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

And the calculation is based on market's wethRewardPerVaultShare.

Market::getVaultAccumulatedValues():

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

Then the rewards are distributed to the vault, so users who stake vault shares can claim the rewards.

Vault::recalculateVaultsCreditCapacity():

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

The problem is that when a market is connected to multiple vaults, the same wethRewardPerVaultShare value is used to calcualte weth reward changes accross all the vaults, this means stakers from different vaults are able to claim the same amount of weth rewards.

Assuming Alice and Bob stake in 2 different vaults, and these 2 vaults are connected to a same market, when the market receives 1 WETH rewards, both Alice and Bob would be able to claim 1 WETH, which is 2 WETH in total, however, there is only 1 WETH for claim, and it eventually leads to insolvency.

Moreover, user who has pending rewards will not able to unstake, their stakes are stucked in vaults.

VaultRouterBranch::unstake():

// reverts if the claimable amount is NOT 0
if (!amountToClaimX18.isZero()) revert Errors.UserHasPendingRewards(actorId, amountToClaimX18.intoUint256());

Please run the coded POC:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import { Test, console } from "forge-std/Test.sol";
import "./Base.t.sol";
import { Errors } from "@zaros/utils/Errors.sol";
import { Vault } from "@zaros/market-making/leaves/Vault.sol";
import { Collateral } from "@zaros/market-making/leaves/Collateral.sol";
contract AuditTest is Base_Test {
address owner = makeAddr("Owner");
address feeRecipient = makeAddr("FeeRecipient");
function setUp() public override {
// Perps Engine Set Up
bool isTestnet = false;
address[] memory branches = deployPerpsEngineBranches(isTestnet);
bytes4[][] memory branchesSelectors = getPerpsEngineBranchesSelectors(isTestnet);
RootProxy.BranchUpgrade[] memory branchUpgrades =
getBranchUpgrades(branches, branchesSelectors, RootProxy.BranchUpgradeAction.Add);
address[] memory initializables = getInitializables(branches);
bytes[] memory initializePayloads = getInitializePayloads(owner);
branchUpgrades = deployPerpsEngineHarnesses(branchUpgrades);
RootProxy.InitParams memory initParams = RootProxy.InitParams({
initBranches: branchUpgrades,
initializables: initializables,
initializePayloads: initializePayloads
});
perpsEngine = IPerpsEngine(address(new MockEngine(initParams)));
// Market Making Engine Set Up
address[] memory mmBranches = deployMarketMakingEngineBranches();
bytes4[][] memory mmBranchesSelectors = getMarketMakerBranchesSelectors();
RootProxy.BranchUpgrade[] memory mmBranchUpgrades =
getBranchUpgrades(mmBranches, mmBranchesSelectors, RootProxy.BranchUpgradeAction.Add);
RootProxy.BranchUpgrade[] memory mmbranchUpgrades = new RootProxy.BranchUpgrade[](mmBranchUpgrades.length);
for (uint256 i; i < mmbranchUpgrades.length; i++) {
mmbranchUpgrades[i] = mmBranchUpgrades[i];
}
initializables = getInitializables(mmBranches);
initializePayloads = getInitializePayloads(owner);
RootProxy.InitParams memory mmEngineInitParams = RootProxy.InitParams({
initBranches: mmbranchUpgrades,
initializables: initializables,
initializePayloads: initializePayloads
});
marketMakingEngine = IMarketMakingEngine(address(new MarketMakingEngine(mmEngineInitParams)));
vm.startPrank(owner);
// Configure Collaterals
uint256[2] memory marginCollateralIdsRange;
marginCollateralIdsRange[0] = INITIAL_MARGIN_COLLATERAL_ID;
marginCollateralIdsRange[1] = FINAL_MARGIN_COLLATERAL_ID;
mockSequencerUptimeFeed = address(new MockSequencerUptimeFeed(0));
configureMarginCollaterals(
IPerpsEngine(perpsEngine), marginCollateralIdsRange, true, mockSequencerUptimeFeed, owner
);
usdc = MockERC20(marginCollaterals[USDC_MARGIN_COLLATERAL_ID].marginCollateralAddress);
usdToken = MockUsdToken(marginCollaterals[USD_TOKEN_MARGIN_COLLATERAL_ID].marginCollateralAddress);
weEth = MockERC20(marginCollaterals[WEETH_MARGIN_COLLATERAL_ID].marginCollateralAddress);
wstEth = MockERC20(marginCollaterals[WSTETH_MARGIN_COLLATERAL_ID].marginCollateralAddress);
wEth = MockERC20(marginCollaterals[WETH_MARGIN_COLLATERAL_ID].marginCollateralAddress);
wBtc = MockERC20(marginCollaterals[WBTC_MARGIN_COLLATERAL_ID].marginCollateralAddress);
marketMakingEngine.configureCollateral(
address(usdc),
marginCollaterals[USDC_MARGIN_COLLATERAL_ID].priceAdapter,
MOCK_PERP_CREDIT_CONFIG_DEBT_CREDIT_RATIO,
true,
marginCollaterals[USDC_MARGIN_COLLATERAL_ID].tokenDecimals
);
marketMakingEngine.configureCollateral(
address(usdToken),
marginCollaterals[USD_TOKEN_MARGIN_COLLATERAL_ID].priceAdapter,
MOCK_PERP_CREDIT_CONFIG_DEBT_CREDIT_RATIO,
true,
marginCollaterals[USD_TOKEN_MARGIN_COLLATERAL_ID].tokenDecimals
);
marketMakingEngine.configureCollateral(
address(wEth),
marginCollaterals[WETH_MARGIN_COLLATERAL_ID].priceAdapter,
MOCK_PERP_CREDIT_CONFIG_DEBT_CREDIT_RATIO,
true,
marginCollaterals[WETH_MARGIN_COLLATERAL_ID].tokenDecimals
);
marketMakingEngine.configureCollateral(
address(wBtc),
marginCollaterals[WBTC_MARGIN_COLLATERAL_ID].priceAdapter,
MOCK_PERP_CREDIT_CONFIG_DEBT_CREDIT_RATIO,
true,
marginCollaterals[WBTC_MARGIN_COLLATERAL_ID].tokenDecimals
);
// Set the wETH address
marketMakingEngine.setWeth(address(wEth));
// Configure Engine
marketMakingEngine.configureEngine(address(perpsEngine), address(usdToken), true);
// Configure Fee Recipient
marketMakingEngine.configureVaultDepositAndRedeemFeeRecipient(feeRecipient);
// Create Zlp Vaults
uint256[2] memory vaultsIdsRange;
vaultsIdsRange[0] = INITIAL_VAULT_ID;
vaultsIdsRange[1] = FINAL_VAULT_ID;
setupVaultsConfig();
createZlpVaults(address(marketMakingEngine), owner, vaultsIdsRange);
// Setup Perp Markets
bool isTest = true;
setupPerpMarketsCreditConfig(isTest, address(perpsEngine), address(usdToken));
vm.stopPrank();
}
function testAudit_OneMarketTwoVaults() public {
vm.startPrank(owner);
// Create WETH Vault
uint128 wethVaultId = WETH_CORE_VAULT_ID;
address wethVaultIndexToken = address(zlpVaults[vaultsConfig[wethVaultId].asset][vaultsConfig[wethVaultId].vaultType]);
ZlpVault wethZlpVault = ZlpVault(wethVaultIndexToken);
{
Collateral.Data memory collateral = Collateral.Data({
creditRatio: vaultsConfig[wethVaultId].creditRatio,
priceAdapter: vaultsConfig[wethVaultId].priceAdapter,
asset: vaultsConfig[wethVaultId].asset,
isEnabled: vaultsConfig[wethVaultId].isEnabled,
decimals: vaultsConfig[wethVaultId].decimals
});
Vault.CreateParams memory vaultCreatParams = Vault.CreateParams({
depositFee: vaultsConfig[wethVaultId].depositFee,
redeemFee: vaultsConfig[wethVaultId].redeemFee,
vaultId: vaultsConfig[wethVaultId].vaultId,
depositCap: vaultsConfig[wethVaultId].depositCap,
withdrawalDelay: vaultsConfig[wethVaultId].withdrawalDelay,
indexToken: wethVaultIndexToken,
engine: address(perpsEngine),
collateral: collateral
});
marketMakingEngine.createVault(vaultCreatParams);
Vault.UpdateParams memory vaultUpdateParams = Vault.UpdateParams({
vaultId: wethVaultId,
depositCap: vaultCreatParams.depositCap,
withdrawalDelay: vaultCreatParams.withdrawalDelay,
isLive: true,
lockedCreditRatio: 0
});
marketMakingEngine.updateVaultConfiguration(vaultUpdateParams);
}
vm.stopPrank();
// Alice deposits into weth vault and stakes
address alice = makeAddr("Alice");
{
uint256 depositAmount = 1e18;
wEth.mint(alice, depositAmount);
vm.startPrank(alice);
// Deposit
wEth.approve(address(marketMakingEngine), depositAmount);
marketMakingEngine.deposit(vaultsConfig[wethVaultId].vaultId, uint128(depositAmount), 0, "", false);
// Stake
uint256 aliceShares = wethZlpVault.balanceOf(alice);
wethZlpVault.approve(address(marketMakingEngine), aliceShares);
marketMakingEngine.stake(wethVaultId, uint128(aliceShares));
vm.stopPrank();
}
vm.startPrank(owner);
// Create USDC Vault
uint128 usdcVaultId = USDC_CORE_VAULT_ID;
address usdcVaultIndexToken = address(zlpVaults[vaultsConfig[usdcVaultId].asset][vaultsConfig[usdcVaultId].vaultType]);
ZlpVault usdcZlpVault = ZlpVault(usdcVaultIndexToken);
{
Collateral.Data memory collateral = Collateral.Data({
creditRatio: vaultsConfig[usdcVaultId].creditRatio,
priceAdapter: vaultsConfig[usdcVaultId].priceAdapter,
asset: vaultsConfig[usdcVaultId].asset,
isEnabled: vaultsConfig[usdcVaultId].isEnabled,
decimals: vaultsConfig[usdcVaultId].decimals
});
Vault.CreateParams memory vaultCreatParams = Vault.CreateParams({
depositFee: vaultsConfig[usdcVaultId].depositFee,
redeemFee: vaultsConfig[usdcVaultId].redeemFee,
vaultId: vaultsConfig[usdcVaultId].vaultId,
depositCap: vaultsConfig[usdcVaultId].depositCap,
withdrawalDelay: vaultsConfig[usdcVaultId].withdrawalDelay,
indexToken: usdcVaultIndexToken,
engine: address(perpsEngine),
collateral: collateral
});
marketMakingEngine.createVault(vaultCreatParams);
Vault.UpdateParams memory vaultUpdateParams = Vault.UpdateParams({
vaultId: usdcVaultId,
depositCap: vaultCreatParams.depositCap,
withdrawalDelay: vaultCreatParams.withdrawalDelay,
isLive: true,
lockedCreditRatio: 0
});
marketMakingEngine.updateVaultConfiguration(vaultUpdateParams);
}
// Bob deposits into USDC vault and stakes
address bob = makeAddr("Bob");
{
uint256 depositAmount = 2000e6;
usdc.mint(bob, depositAmount);
vm.startPrank(bob);
// Deposit
usdc.approve(address(marketMakingEngine), depositAmount);
marketMakingEngine.deposit(vaultsConfig[usdcVaultId].vaultId, uint128(depositAmount), 0, "", false);
// Stake
uint256 bobShares = usdcZlpVault.balanceOf(bob);
usdcZlpVault.approve(address(marketMakingEngine), bobShares);
marketMakingEngine.stake(usdcVaultId, uint128(bobShares));
vm.stopPrank();
}
vm.startPrank(owner);
// Configure Market
uint128 marketCreditConfigId = ARB_PERP_MARKET_CREDIT_CONFIG_ID;
uint128 marketId = perpMarketsCreditConfig[marketCreditConfigId].marketId;
marketMakingEngine.configureMarket(
address(perpsEngine),
perpMarketsCreditConfig[marketCreditConfigId].marketId,
perpMarketsCreditConfig[marketCreditConfigId].autoDeleverageStartThreshold,
perpMarketsCreditConfig[marketCreditConfigId].autoDeleverageEndThreshold,
perpMarketsCreditConfig[marketCreditConfigId].autoDeleverageExpoentZ
);
marketMakingEngine.unpauseMarket(marketId);
// Connect Vaults and Market
uint256[] memory marketIds = new uint256[](1);
marketIds[0] = marketId;
uint256[] memory vaultIds = new uint256[](2);
vaultIds[0] = vaultsConfig[wethVaultId].vaultId;
vaultIds[1] = vaultsConfig[usdcVaultId].vaultId;
marketMakingEngine.connectVaultsAndMarkets(marketIds, vaultIds);
// Call Vault::recalculateVaultsCreditCapacity()
marketMakingEngine.updateVaultCreditCapacity(wethVaultId);
marketMakingEngine.updateVaultCreditCapacity(usdcVaultId);
vm.stopPrank();
// Receive 1 ether Market Fee
wEth.mint(address(perpsEngine), 1 ether);
vm.startPrank(address(perpsEngine));
wEth.approve(address(marketMakingEngine), 1 ether);
marketMakingEngine.receiveMarketFee(marketId, address(wEth), 1 ether);
vm.stopPrank();
// Alice claims WETH rewards
vm.prank(alice);
marketMakingEngine.claimFees(wethVaultId);
// Bob won't be able to claims as there is not enough funds
vm.prank(bob);
vm.expectRevert(abi.encodeWithSelector(bytes4(keccak256("ERC20InsufficientBalance(address,uint256,uint256)")), address(marketMakingEngine), 1, 999999999999999999));
marketMakingEngine.claimFees(usdcVaultId);
// Bob won't be able to unstake neither
vm.startPrank(bob);
uint256 bobShares = usdcZlpVault.balanceOf(bob);
vm.expectRevert(abi.encodeWithSelector(Errors.UserHasPendingRewards.selector, bytes32(uint256(uint160(bob))), 999999999999999999));
marketMakingEngine.unstake(usdcVaultId, bobShares);
vm.stopPrank();
}
}

Impact

User won't be able to claim staking rewards, and their staked shares are stucked.

Tools Used

Manual Review

Recommendations

Weth rewards should be distributed based on the vaults' delegated shares.

Updates

Lead Judging Commences

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

Give us feedback!