Summary
When market receive profit we call the depositCreditForMarket
than we update the relevant collateral state , which will be applied to all the connected vaults of this market , in case of swapping of usd tokens with the vault assets. we apply premium/discount factor, depending on the state of vault either profit or lose.However the State of connected vaults were not updated in these calls which will effect the the discountPremiumFactor of vault. further details in next section.
Vulnerability Details
Here I focused on collateralAddr==usdc
. when market receive funds we will call settleCreditDeposit
:
/src/market-making/branches/CreditDelegationBranch.sol:181
181: function depositCreditForMarket(
182: uint128 marketId,
183: address collateralAddr,
184: uint256 amount
185: )
186: external
187: onlyRegisteredEngine(marketId)
188: {
189: if (amount == 0) revert Errors.ZeroInput("amount");
190:
191:
192: Collateral.Data storage collateral = Collateral.load(collateralAddr);
193: collateral.verifyIsEnabled();
194:
195:
196:
197:
198: Market.Data storage market = Market.loadLive(marketId);
199: if (market.getTotalDelegatedCreditUsd().isZero()) {
200: revert Errors.NoDelegatedCredit(marketId);
201: }
202:
203:
204: UD60x18 amountX18 = collateral.convertTokenAmountToUd60x18(amount);
205:
206:
207: address usdToken = MarketMakingEngineConfiguration.load().usdTokenOfEngine[msg.sender];
208:
209:
210: address usdc = MarketMakingEngineConfiguration.load().usdc;
211:
212: if (collateralAddr == usdToken) {
213:
214: market.updateNetUsdTokenIssuance(unary(amountX18.intoSD59x18()));
215: } else {
216: if (collateralAddr == usdc) {
217: market.settleCreditDeposit(address(0), amountX18);
218: } else {
219:
220:
221: market.depositCredit(collateralAddr, amountX18);
222: }
223: }
224:
225:
226:
227:
228: IERC20(collateralAddr).safeTransferFrom(msg.sender, address(this), amount);
229:
230: emit LogDepositCreditForMarket(msg.sender, marketId, collateralAddr, amount);
231: }
in settleCreditDeposit
function we update usdcCreditPerVaultShare
and realizedDebtUsdPerVaultShare
.
/src/market-making/leaves/Market.sol:444
444: function settleCreditDeposit(Data storage self, address settledAsset, UD60x18 netUsdcReceivedX18) internal {
445:
446: self.creditDeposits.remove(settledAsset);
447:
448:
449: UD60x18 addedUsdcPerCreditShareX18 = netUsdcReceivedX18.div(ud60x18(self.totalDelegatedCreditUsd));
450:
451:
452: self.usdcCreditPerVaultShare =
453: ud60x18(self.usdcCreditPerVaultShare).add(addedUsdcPerCreditShareX18).intoUint128();
454:
455:
456: self.realizedDebtUsdPerVaultShare = sd59x18(self.realizedDebtUsdPerVaultShare).sub(
457: addedUsdcPerCreditShareX18.intoSD59x18()
458: ).intoInt256().toInt128();
459:
460: }
realizedDebtUsdPerVaultShare
is important to find that the vault is in credit or debt.
/src/market-making/leaves/Vault.sol:278
278: function _recalculateConnectedMarketsState(
279: Data storage self,
280: uint128[] memory connectedMarketsIdsCache,
281: bool shouldRehydrateCache
282: )
283: private
284: returns (
285: uint128[] memory rehydratedConnectedMarketsIdsCache,
286: SD59x18 vaultTotalRealizedDebtChangeUsdX18,
287: SD59x18 vaultTotalUnrealizedDebtChangeUsdX18,
288: UD60x18 vaultTotalUsdcCreditChangeX18,
289: UD60x18 vaultTotalWethRewardChangeX18
290: )
291: {
...
330:
331: if (!market.getTotalDelegatedCreditUsd().isZero()) {
332:
333:
334: (
335: ctx.realizedDebtChangeUsdX18,
336: ctx.unrealizedDebtChangeUsdX18,
337: ctx.usdcCreditChangeX18,
338: ctx.wethRewardChangeX18
339: ) = market.getVaultAccumulatedValues(
340: ud60x18(creditDelegation.valueUsd),
341: sd59x18(creditDelegation.lastVaultDistributedRealizedDebtUsdPerShare),
342: sd59x18(creditDelegation.lastVaultDistributedUnrealizedDebtUsdPerShare),
343: ud60x18(creditDelegation.lastVaultDistributedUsdcCreditPerShare),
344: ud60x18(creditDelegation.lastVaultDistributedWethRewardPerShare)
345: );
346: }
...
350: if (
351: ctx.realizedDebtChangeUsdX18.isZero() && ctx.unrealizedDebtChangeUsdX18.isZero()
352: && ctx.usdcCreditChangeX18.isZero() && ctx.wethRewardChangeX18.isZero()
353: ) {
354: continue;
355: }
356:
...
371:
372:
373: creditDelegation.updateVaultLastDistributedValues(
374: sd59x18(market.realizedDebtUsdPerVaultShare),
375: sd59x18(market.unrealizedDebtUsdPerVaultShare),
376: ud60x18(market.usdcCreditPerVaultShare),
377: ud60x18(market.wethRewardPerVaultShare)
378: );
379: }
380: }
This function will update realizedDebtUsdPerVaultShare
per vault.
To find the getAmountOfAssetOut
we first get the getTotalDebt()
of vault than we passed that debt to getPremiumDiscountFactor
function.
/src/market-making/branches/StabilityBranch.sol:100
100: function getAmountOfAssetOut(
101: uint128 vaultId,
102: UD60x18 usdAmountInX18,
103: UD60x18 indexPriceX18
104: )
105: public
106: view
107: returns (UD60x18 amountOutX18)
108: {
109:
110: Vault.Data storage vault = Vault.load(vaultId);
111:
112:
113:
114: UD60x18 vaultAssetsUsdX18 = ud60x18(IERC4626(vault.indexToken).totalAssets()).mul(indexPriceX18);
115: if (vaultAssetsUsdX18.isZero()) revert Errors.InsufficientVaultBalance(vaultId, 0, 0);
116:
117:
118: SD59x18 vaultDebtUsdX18 = vault.getTotalDebt();
119:
120:
121:
122:
123: UD60x18 premiumDiscountFactorX18 =
124: UsdTokenSwapConfig.load().getPremiumDiscountFactor(vaultAssetsUsdX18, vaultDebtUsdX18);
125:
126:
127: amountOutX18 = usdAmountInX18.div(indexPriceX18).mul(premiumDiscountFactorX18);
130:
131: }
getTotalDebt
will return the debt of a vault on basis of this than we will either apply discount or premium.
/src/market-making/leaves/Vault.sol:227
227: function getTotalDebt(Data storage self) internal view returns (SD59x18 totalDebtUsdX18) {
228: totalDebtUsdX18 = getUnsettledRealizedDebt(self).add(sd59x18(self.marketsUnrealizedDebtUsd));
229: }
....
241: function getUnsettledRealizedDebt(Data storage self)
242: internal
243: view
244: returns (SD59x18 unsettledRealizedDebtUsdX18)
245: {
246: unsettledRealizedDebtUsdX18 =
247: sd59x18(self.marketsRealizedDebtUsd).add(unary(ud60x18(self.depositedUsdc).intoSD59x18()));
248: }
So now lets have a look on how we calculate the getPremiumDiscountFactor
.
function getPremiumDiscountFactor(
Data storage self,
UD60x18 vaultAssetsValueUsdX18,
SD59x18 vaultDebtUsdX18
)
internal
view
returns (UD60x18 premiumDiscountFactorX18)
{
UD60x18 vaultDebtTvlRatioAbs = vaultDebtUsdX18.abs().intoUD60x18().div(vaultAssetsValueUsdX18);
UD60x18 pdCurveXMinX18 = ud60x18(0.1e18);
UD60x18 pdCurveXMaxX18 =ud60x18(0.8e18);
if (vaultDebtTvlRatioAbs.lte(pdCurveXMinX18)) {
premiumDiscountFactorX18 = UD60x18_UNIT;
return premiumDiscountFactorX18;
}
UD60x18 pdCurveXX18 = vaultDebtTvlRatioAbs.gte(pdCurveXMaxX18) ? pdCurveXMaxX18 : vaultDebtTvlRatioAbs;
UD60x18 pdCurveYMinX18 = ud60x18(0.3e18);
UD60x18 pdCurveYMaxX18 = ud60x18(0.9e18);
UD60x18 pdCurveZX18 = ud60x18(0.6e18);
UD60x18 pdCurveYX18 = pdCurveYMinX18.add(
pdCurveYMaxX18.sub(pdCurveYMinX18).mul(
pdCurveXX18.sub(pdCurveXMinX18).div(pdCurveXMaxX18.sub(pdCurveXMinX18)).pow(pdCurveZX18)
)
);
premiumDiscountFactorX18 =
vaultDebtUsdX18.lt(SD59x18_ZERO) ? UD60x18_UNIT.sub(pdCurveYX18) : UD60x18_UNIT.add(pdCurveYX18);
}
Note Here I have assigned dummy data to pdCurveXMinX18
, pdCurveXMaxX18
, pdCurveYMinX18
, pdCurveYMaxX18
and pdCurveZX18
because if not it will revert which is separate issue i have submitted.
So now lets just wrap it:
Assume the vault is in +debt , we deposit USDC
to the market which will give us new value of self.realizedDebtUsdPerVaultShare
and self.usdcCreditPerVaultShare
which we were suppose to apply to the all vaults connect with this market but we are not doing it for impact see the impact section of report
. The only calls to the function updateVaultCreditCapacity
will not fix this because the lastVaultDistributedRealizedDebtUsdPerShareX18
will be zero and no changes would be applied to the vault.
/src/market-making/leaves/Market.sol:303
303: realizedDebtChangeUsdX18 = !lastVaultDistributedRealizedDebtUsdPerShareX18.isZero()
304: ?sd59x18(self.realizedDebtUsdPerVaultShare).sub(lastVaultDistributedRealizedDebtUsdPerShareX18).mul(vaultCreditShareX18.intoSD59x18())
305: : SD59x18_ZERO;
306:
POC
I have assume few things for POC :
Add dummy value to the function getPremiumDiscountFactor
you can copy it from above section.
Called the receiveMarketFee
function so that subsequent call to updateVaultCreditCapacity
will apply latest changes to the vault.
function test_depositCreditForMarket_state_no_update(
)
external
whenCallerIsKeeper
whenRequestWasNotYetProcessed
whenSwapRequestNotExpired
{
uint256 vaultId = 100;
uint256 marketId = 100;
uint256 vaultAssetsBalance = 5000e16;
uint256 swapAmount = 1000e6;
uint128 vaultDebtAbsUsd = uint128(2e18);
bool useCredit= false;
TestFuzz_WhenSlippageCheckPassesAndThePremiumOrDiscountIsNotZero_Context memory ctx;
ctx.fuzzVaultConfig = getFuzzVaultConfig(vaultId);
ctx.oneAsset = 10 ** ctx.fuzzVaultConfig.decimals;
changePrank({ msgSender: users.owner.account });
marketMakingEngine.configureUsdTokenSwapConfig(1, 30, type(uint96).max);
marketMakingEngine.workaround_setVaultDebt(uint128(ctx.fuzzVaultConfig.vaultId),int128(vaultDebtAbsUsd));
marketMakingEngine.workaround_setVaultDepositedUsdc(uint128(ctx.fuzzVaultConfig.vaultId),0);
marketMakingEngine.configureEngine(address(marketMakingEngine), address(newUSDToken), true);
changePrank({ msgSender: users.naruto.account });
console.log("----------vaultDebtAbsUsd-----------",vaultDebtAbsUsd);
deal({
token: address(ctx.fuzzVaultConfig.asset),
to: ctx.fuzzVaultConfig.indexToken,
give: vaultAssetsBalance
});
swapAmount = vaultAssetsBalance;
console.log("---------------swapAmount---------------",swapAmount);
deal({ token: address(usdToken), to: users.naruto.account, give: swapAmount });
ctx.fuzzPerpMarketCreditConfig = getFuzzPerpMarketCreditConfig(marketId);
ctx.engine = IMockEngine(perpMarketsCreditConfig[ctx.fuzzPerpMarketCreditConfig.marketId].engine);
ctx.minAmountOut = 0;
UD60x18 priceUsdX18 = IPriceAdapter(vaultsConfig[ctx.fuzzVaultConfig.vaultId].priceAdapter).getPrice();
ctx.priceData = getMockedSignedReport(ctx.fuzzVaultConfig.streamId, priceUsdX18.intoUint256());
ctx.usdTokenSwapKeeper = usdTokenSwapKeepers[ctx.fuzzVaultConfig.asset];
deal({ token: address(usdc), to: address(ctx.engine), give: 10_000e30});
changePrank({ msgSender: address(ctx.engine) });
uint256 marketFees = 1_000_000_000_000_000_000;
IERC20 wethFeeToken = IERC20(getFuzzVaultConfig(WETH_CORE_VAULT_ID).asset);
deal({ token: address(wethFeeToken), to: address(ctx.engine), give: marketFees });
changePrank({ msgSender: address((ctx.engine)) });
marketMakingEngine.depositCreditForMarket(ctx.fuzzPerpMarketCreditConfig.marketId,address(usdc),1_000_000_000_000_000);
marketMakingEngine.receiveMarketFee(ctx.fuzzPerpMarketCreditConfig.marketId, address(wethFeeToken), marketFees);
marketMakingEngine.depositCreditForMarket(ctx.fuzzPerpMarketCreditConfig.marketId,address(usdc),1_000_000_000_000_000_00);
UD60x18 positveDebtAmountOut =
marketMakingEngine.getAmountOfAssetOut(ctx.fuzzVaultConfig.vaultId, ud60x18(swapAmount), priceUsdX18);
UD60x18 negativeDebtAmountOut =
marketMakingEngine.getAmountOfAssetOut(ctx.fuzzVaultConfig.vaultId, ud60x18(swapAmount), priceUsdX18);
console.log("+veCase" , positveDebtAmountOut.intoUint256());
console.log("-veCase" , negativeDebtAmountOut.intoUint256());
assertEq(positveDebtAmountOut.intoUint256() , negativeDebtAmountOut.intoUint256());
}
run with command : forge test --mt test_depositCreditForMarket_state -vvvv
.
In the above case, both values are equal, indicating that the vault still applies a discount even though it is supposed to charge a premium. To test other scenarios, uncomment updateVaultCreditCapacity
.
Impact
The vault will still give discount or charge premium due not not updating the vault debt state. it will result in lose for vault in case the vault is in credit or vice versa.
Tools Used
Manual Review
Recommendations
The Fix for the issue lies in these two functions :
getVaultAccumulatedValues
depositCreditForMarket
For getVaultAccumulatedValues following fix is recommended:
@@ -299,11 +299,9 @@ library Market {
// 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
// console.log("lastVaultDistributedRealizedDebtUsdPerShareX18------------",lastVaultDistributedRealizedDebtUsdPerShareX18.intoInt256());
- realizedDebtChangeUsdX18 = !lastVaultDistributedRealizedDebtUsdPerShareX18.isZero()
- ? realizedDebtChangeUsdX18
- : SD59x18_ZERO;
+ // realizedDebtChangeUsdX18 = ;
+ realizedDebtChangeUsdX18 = sd59x18(self.realizedDebtUsdPerVaultShare).sub(lastVaultDistributedRealizedDebtUsdPerShareX18).mul(vaultCreditShareX18.intoSD59x18());
+ // : SD59x18_ZERO;
Add the Following changes to CreditDelegationBranch
@@ -208,11 +208,9 @@ contract CreditDelegationBranch is EngineAccessControl {
// caches the usdc
address usdc = MarketMakingEngineConfiguration.load().usdc;
// note: storage updates must occur using zaros internal precision
if (collateralAddr == usdToken) {
// if the deposited collateral is USD Token, it reduces the market's realized debt
market.updateNetUsdTokenIssuance(unary(amountX18.intoSD59x18())); // @note -amountX18
} else {
@@ -229,6 +227,9 @@ contract CreditDelegationBranch is EngineAccessControl {
// PerpsEngineConfigurationBranch::setMarketMakingEngineAllowance
// note: transfers must occur using token native precision
IERC20(collateralAddr).safeTransferFrom(msg.sender, address(this), amount);
+
+ Vault.recalculateVaultsCreditCapacity(market.getConnectedVaultsIds());
+
// emit an event
emit LogDepositCreditForMarket(msg.sender, marketId, collateralAddr, amount);
}