Halborn Logo

Archimedes Finance - Archimedes


Prepared by:

Halborn Logo

HALBORN

Last Updated 04/26/2024

Date of Engagement by: July 28th, 2022 - August 18th, 2022

Summary

0% of all REPORTED Findings have been addressed

All findings

32

Critical

3

High

3

Medium

6

Low

2

Informational

18


1. INTRODUCTION

Archimedes engaged Halborn to conduct a security audit on their finance protocol beginning on July 28th, 2022 and ending on August 18th, 2022. The security assessment was scoped to the smart contracts provided in the Archimedes_Finance GitHub repository thisisarchimedes/Archimedes_Finance.

2. AUDIT SUMMARY

The team at Halborn was provided three weeks for the engagement and assigned one full-time security engineer to audit the security of the smart contract. The security engineers are blockchain and smart-contract security experts with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.

The purpose of this audit to achieve the following:

    • Ensure that all functions in the protocol smart contracts are intended.

    • Identify potential security issues in Arcade bridge smart contracts.

In summary, Halborn identified many security risks that should be addressed by the Archimedes Team.

3. TEST APPROACH & METHODOLOGY

Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this audit. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the bridge code and can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of the audit:

    • Research into architecture and purpose

    • Smart contract manual code review and walkthrough

    • Graphing out functionality and contract logic/connectivity/functions (solgraph)

    • Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes

    • Manual testing by custom scripts

    • Scanning of solidity files for vulnerabilities, security hotspots or bugs. (MythX)

    • Static Analysis of security for scoped contract, and imported functions. (Slither)

    • Testnet deployment (Brownie, Remix IDE)

4. SCOPE

The security assessment was scoped to the Solidity smart contracts of the audit branch

\begin{enumerate} \item Solidity Smart Contracts \begin{enumerate} \item Repository: \href{https://github.com/thisisarchimedes/Archimedes_Finance/tree/AuditFreeze}{Archimedes Finance} \item Commit ID: \href{https://github.com/thisisarchimedes/Archimedes_Finance/tree/ea067278709c18a98d6da459645d672af5352286}{ea067278709c18a98d6da459645d672af5352286} \end{enumerate} \end{enumerate}

Out-of-scope: External libraries and financial related attacks.

    • The Proxy contracts that will be used by Archimedes Fi team to deploy the upgradeable contracts were not in the scope of this audit

5. RISK METHODOLOGY

Vulnerabilities or issues observed by Halborn are ranked based on the risk assessment methodology by measuring the LIKELIHOOD of a security incident and the IMPACT should an incident occur. This framework works for communicating the characteristics and impacts of technology vulnerabilities. The quantitative model ensures repeatable and accurate measurement while enabling users to see the underlying vulnerability characteristics that were used to generate the Risk scores. For every vulnerability, a risk level will be calculated on a scale of 5 to 1 with 5 being the highest likelihood or impact.
RISK SCALE - LIKELIHOOD
  • 5 - Almost certain an incident will occur.
  • 4 - High probability of an incident occurring.
  • 3 - Potential of a security incident in the long term.
  • 2 - Low probability of an incident occurring.
  • 1 - Very unlikely issue will cause an incident.
RISK SCALE - IMPACT
  • 5 - May cause devastating and unrecoverable impact or loss.
  • 4 - May cause a significant level of impact or loss.
  • 3 - May cause a partial impact or loss to many.
  • 2 - May cause temporary impact or loss.
  • 1 - May cause minimal or un-noticeable impact.
The risk level is then calculated using a sum of these two values, creating a value of 10 to 1 with 10 being the highest level of security risk.
Critical
High
Medium
Low
Informational
  • 10 - CRITICAL
  • 9 - 8 - HIGH
  • 7 - 6 - MEDIUM
  • 5 - 4 - LOW
  • 3 - 1 - VERY LOW AND INFORMATIONAL

6. SCOPE

Out-of-Scope: New features/implementations after the remediation commit IDs.

7. Assessment Summary & Findings Overview

Critical

3

High

3

Medium

6

Low

2

Informational

18

Impact x Likelihood

HAL-14

HAL-05

HAL-06

HAL-04

HAL-01

HAL-02

HAL-03

HAL-08

HAL-09

HAL-11

HAL-12

HAL-15

HAL-16

HAL-07

HAL-10

HAL-13

HAL-18

HAL-19

HAL-20

HAL-21

HAL-22

HAL-23

HAL-24

HAL-25

HAL-26

HAL-27

HAL-28

HAL-29

HAL-30

HAL-31

HAL-32

HAL-17

Security analysisRisk levelRemediation Date
POSITIONS CANNOT BE UNWOUND DUE TO OUSD BEHAVIORCritical-
POSITIONS CANNOT BE UNWOUND ALTHOUGH ENOUGH OUSD IS HELDCritical-
CONTRACT DENIAL OF SERVICE DUE TO INTEGER UNDERFLOWCritical-
UNUSED ARCH NOT RETURNED WHEN OPENING A POSITIONHigh-
INCORRECT CALCULATEARCHNEEDEDFORLEVERAGE CALCULATIONHigh-
CONTRACT DEPENDENCIES CANNOT BE MODIFIEDHigh-
LACK OF PARAMETER PRECISIONMedium-
MISLEADING REVERT MESSAGESMedium-
OWNER CAN RENOUNCE OWNERSHIPMedium-
LACK OF PAUSE FUNCTIONALITYMedium-
LACK OF PARAMETER LIMITSMedium-
ZERO LEVERAGE POSITIONS CAN BE OPENEDMedium-
OUSDTOTAL VARIABLE SHOULD BE REMOVEDLow-
INCONSISTENT PARAMETER FORMATTINGLow-
LACK OF TRANSFEROWNERSHIP PATTERNInformational-
UNPROTECTED GLOBALCOLLATERALRATE PARAMETERInformational-
POSITIONS CANNOT BE UNWOUND WHEN TRANSFERREDInformational-
UNUSED CODEInformational-
REDUNDANT STATEMENTSInformational-
MISLEADING CODE COMMENTSInformational-
FEE RATES DIFFERS FROM WHITEPAPERInformational-
USE ++I INSTEAD OF I++ IN LOOPS FOR GAS OPTIMIZATIONInformational-
UNDEPLOYABLE CONTRACTSInformational-
UNUSED IMPORTSInformational-
VAULTS NEED TO ENABLE OUSD REBASESInformational-
UNINITIALIZED CONTRACTSInformational-
FEE-ON-TRANSFER TOKENS NOT SUPPORTEDInformational-
UNCHECKED TRANSFERInformational-
UNUSED RETURNInformational-
HARDCODED SLIPPAGEInformational-
UNUSED FUNCTIONSInformational-
POSSIBLE MISUSE OF PUBLIC FUNCTIONSInformational-

8. Findings & Tech Details

8.1 POSITIONS CANNOT BE UNWOUND DUE TO OUSD BEHAVIOR

// Critical

Description

It has been observed that once a user tries to unwind any position (supplying the positionTokenID of any owned NFT), multiple operations are performed, so the user can retrieve the OUSD initially sent. Once the final OUSD amount is transferred to the user, a require statement is placed to check that the correct amount of OUSD has been transferred to the user.

However, due to OUSD behavior when transferring and querying for token balances, this requirement statement could not get fulfilled when transferring non-rounded amounts, causing the transaction to revert and positions to become unwindable.

Coordinator.sol

    function _withdrawCollateralUnderNFT(
        uint256 _nftId,
        uint256 _amount,
        address _to
    ) internal {
        /// Method makes sure ousd recorded balance transfer
        // TODO: Do we really need this check? Seems excessive
        uint256 userOusdBalanceBeforeWithdraw = _ousd.balanceOf(_to);
        _ousd.safeTransfer(_to, _amount);
        require(_ousd.balanceOf(_to) == userOusdBalanceBeforeWithdraw + _amount, "OUSD transfer balance incorrect");
        _cdp.withdrawOUSDFromPosition(_nftId, _amount);
    }

This behavior could also create inaccuracies when handling vault rebases or in any situation where checking the balance of an account is needed.

A new position has been opened by user1, depositing 10 OUSD for 10 cycles. When trying to unwind the position, the transaction gets reverted with the following error:

errorUnwinding1.png

Checking the call traces of the transaction, the OUSD balance of user1 before unwinding the position is 990000000000000000000, equivalent to 990 OUSD:

user1BalBefore.png

The amount of OUSD to be transferred back to user1 is 7122076543972609693, ~7.12 OUSD:

transferAmount.png

After the transfer is performed, the balance of user1 is checked again for the require statement:

user1BalAfter.png

Due to OUSD behavior, the requirement statement does not get fulfilled, reverting the transaction since 990000000000000000000 + 7122076543972609693 = 997122076543972609693, and user1's balance is 997122076543972609692.

This behavior can be easily observed just by transferring some tokens to any user and checking the balance immediately after:

ousdTransfer.png
Score
Impact: 5
Likelihood: 5

8.2 POSITIONS CANNOT BE UNWOUND ALTHOUGH ENOUGH OUSD IS HELD

// Critical

Description

Once any user tries to unwind a position, one of the multiple steps performed is to redeem every OUSD available in the vault under that position and:

  1. Check the LVUSD amount that was initially borrowed and has to be repaid in order to close the position.
  2. Calculate the needed amount of OUSD that will have to be exchanged for the amount of LvOUSD obtained in the previous step (plus an extra 1% for slippage protection).
  3. Check that the needed amount of OUSD is not greater than the OUSD available. Otherwise, the user will not be able to close the position since he cannot repay the LvOUSD initially borrowed.

Then, this minimum required amount of OUSD calculated is swapped for LvOUSD, and the output is then compared again with the initially borrowed LvOUSD.

If the amount of swapped LvOUSD is enough to repay the initially borrowed LvOUSD, it will be transferred back to the Coordinator, and the remaining OUSD amount will be transferred to the user as collateral reimbursement (and profits, if any).

However, if the calculated amount of needed OUSD does not finally get as many LvUSD as calculated (due to slippage, imbalanced pool, etc.), the transaction will be reverted with Not enough LvUSD in pool error, remaining unwindable until the OUSD amount calculated for swapping actually gets enough LvUSD back, although more than enough OUSD for closing the position is held.

This behavior goes against what is described in Archimedes Documentation, which states that any user holding enough OUSD to pay for the debt should be able to close the position:

It also has been detected that _neededOUSDWithSlippage is not being accurately calculated since it assumes that the estimated amount calculated using get_dy functions will remain constant in both directions of the swap (LvUSD -> 3CRV -> OUSD and OUSD -> 3CRV -> LvUSD), and only a 1% slippage protection has been hardcoded (which would have to absorb swap miscalculations, actual slippage, etc.), which might be not enough and introduces an artificial position unwind limit of ~2.3%, meaning that any position borrowing a LvUSD amount greater than 2.3% of the total pool balance (LvUSD + 3CRV balances) will become unwindable.

Code Location

Exchanger.sol

       function _swapOUSDforLvUSD(uint256 amountOUSD, uint256 minRequiredLvUSD) internal returns (uint256 lvUSDReturned, uint256 remainingOUSD) {
        // Estimate "neededOUSD" using get_dy()
        uint256 _needed3CRV = _poolLvUSD3CRV.get_dy(_indexLvUSD, _index3CRV, minRequiredLvUSD);
        uint256 _neededOUSD = _poolOUSD3CRV.get_dy(_index3CRV, _indexOUSD, _needed3CRV);
        uint256 _neededOUSDWithSlippage = (_neededOUSD * 101) / 100;

        require(amountOUSD >= _neededOUSDWithSlippage, "Not enough OUSD for exchange");

        // We lose some $ from fees and slippage
        // multiply _neededOUSD * 103%
        uint256 _returned3CRV = _xOUSDfor3CRV(_neededOUSDWithSlippage);

        uint256 _returnedLvUSD = _x3CRVforLvUSD(_returned3CRV);
        require(_returnedLvUSD >= minRequiredLvUSD, "Not enough LvUSD in pool");

        // calculate remaining OUSD
        remainingOUSD = amountOUSD - _neededOUSDWithSlippage;
        _ousd.safeTransfer(_addressCoordinator, remainingOUSD);

        // send all swapped lvUSD to coordinator
        _lvusd.safeTransfer(_addressCoordinator, _returnedLvUSD);

        return (_returnedLvUSD, remainingOUSD);
    }

A new position has been opened by user1, depositing 10 OUSD for 10 cycles. When trying to unwind the position, the transaction will get reverted with a Not enough LvUSD in pool error. After that, calltrace will be shown in order to inspect the relevant values involved (only relevant values will be displayed):

closePoc1.png closePoc2.png

This is the call performed to Exchanger.swapOUSDforLvUSD. The total amount of OUSD held in the position is 65.44831518184307 OUSD, and the minimum amount of LvUSD to repay is 58.618940391 LvUSD:

closePoc3.png

And the amount of LvUSD received by the calculated amount of OUSD is 58.4852340246938 LvUSD, which is lower than the minimum amount needed:

closePoc4.png

The user has indeed enough OUSD to close the position, but not enough are taken.

To ilustrate the _neededOUSDWithSlippage miscalculation, a few swap estimations have been performed using the contract's implemented logic. The initial condition is a balanced LvUSD/3CRV pool with 100_000 LvUSD and 100_000 3CRV (assuming no slippage):

calc1.png

An alternative method to estimate the OUSD amount to be swapped can be used, preventing transactions from reverting and actually allowing users to unwind their positions:

calc2.png
Score
Impact: 5
Likelihood: 5

8.3 CONTRACT DENIAL OF SERVICE DUE TO INTEGER UNDERFLOW

// Critical

Description

When any user calls createLeveragedPosition() from LeverageEngine contract, depositCollateralUnderNFT() is called, and this function also calls archimedesDeposit() from the OUSD Vault, in order to actually deposit the OUSD provided by the user in the vault and issue the corresponding vault shares.

One of the instructions executed when calling archimedesDeposit() is to call _takeRebaseFees(), which compares the current OUSD balance of the pool (by calling OUSD.balanceOf()) with the last known amount of OUSD held in the vault (stored in _assetsHandledByArchimedes) to look for any possible OUSD rebase, which would have increased vault's OUSD balance.

However, it has been detected that due to the behavior of OUSD when querying for balances described in the previous finding, _assetsHandledByArchimedes could be greater than totalAssets(), causing an integer underflow every time _takeRebaseFees() gets called, and effectively blocking the contracts (since no position could be opened or unwound in this state) until OUSD gets rebased (making totalAssets() greater or equal than _assetsHandledByArchimedes).

Code Location

VaultOUSD.sol

    function _takeRebaseFees() internal {
        uint256 unhandledRebasePayment = totalAssets() - _assetsHandledByArchimedes;
        /// only run fee collection if there are some rebased funds not handled
        if (unhandledRebasePayment > 0) {
            uint256 feeToCollect = (unhandledRebasePayment * _paramStore.getRebaseFeeRate()) / 1 ether;
            uint256 handledRebaseValueToKeepInVault = unhandledRebasePayment - feeToCollect;

            _assetsHandledByArchimedes += handledRebaseValueToKeepInVault;

            _ousd.transfer(_paramStore.getTreasuryAddress(), feeToCollect);
        }
    }

In order to improve variable visibility, _assetsHandledByArchimedes has been declared as public instead of internal.

For this proof of concept, a user will try to open two consecutive positions, and values used in _takeRebaseFees will be analyzed:

contractDos.png
Score
Impact: 5
Likelihood: 5

8.4 UNUSED ARCH NOT RETURNED WHEN OPENING A POSITION

// High

Description

Opening a position requires the following user input:

  • Amount of OUSD that will be used as collateral
  • Number of leverage cycles
  • The required amount of ARCH to burn for the position

When calling createLeveragedPosition() from LeverageEngine.sol contract, the first instruction calculates the corresponding LvUSD to be unlocked with the provided collateral and leverage cycles. The second one checks the maximum amount of LvUSD that could be claimed with the ARCH amount supplied, reverting if the result of the second instruction is lower than the first one.

This effectively checks if the user provided at least the minimum amount of ARCH required for opening the position, but it does not check if the user provided more ARCH than is actually needed.

The next instruction just sends the ARCH amount provided by the user to the treasury account, effectively taking all of the ARCH provided.

Code Location

LeverageEngine.sol

function createLeveragedPosition(
        uint256 ousdPrinciple,
        uint256 cycles,
        uint256 archAmount
    ) external nonReentrant returns (uint256) {
        uint256 lvUSDAmount = _parameterStore.getAllowedLeverageForPosition(ousdPrinciple, cycles);
        uint256 lvUSDAmountAllocatedFromArch = _parameterStore.calculateLeverageAllowedForArch(archAmount);
        /// Revert if not enough Arch token for needed leverage. Continue if too much arch is given
        require(lvUSDAmountAllocatedFromArch >= lvUSDAmount, "Not enough Arch provided");

        uint256 availableLev = _coordinator.getAvailableLeverage();
        require(availableLev >= lvUSDAmount, "Not enough available lvUSD");

        _burnArchTokenForPosition(msg.sender, archAmount);
        uint256 positionTokenId = _positionToken.safeMint(msg.sender);
        _ousd.safeTransferFrom(msg.sender, _addressCoordinator, ousdPrinciple);
        _coordinator.depositCollateralUnderNFT(positionTokenId, ousdPrinciple);
        _coordinator.getLeveragedOUSD(positionTokenId, lvUSDAmount);

        emit PositionCreated(msg.sender, positionTokenId, ousdPrinciple, lvUSDAmount, archAmount);
        return positionTokenId;
    }

A new position has been opened by user1, depositing 10 OUSD for 10 cycles. The amount of ARCH needed for opening the position is 58, but the user supplies 1000, just to be sure.

extraArch.png
Score
Impact: 5
Likelihood: 4

8.5 INCORRECT CALCULATEARCHNEEDEDFORLEVERAGE CALCULATION

// High

Description

The calculateArchNeededForLeverage() function is defined within the ParameterStore.sol contract, and its intended use is to calculate how much ARCH needs to be sent along with the initial amount of OUSD to open the position.

However, it has been detected that the calculation is performed incorrectly due to the order of the operations performed.

This is how the ARCH amount is calculated:

\begin{center} \begin{math} (leverageAmount / _archToLevRatio) * 1 ether \end{math} \end{center}

Since the division is performed before the multiplication, the result of the division will be rounded down to the closest integer, having that if the real amount of ARCH needed is 58.751, the result of the calculation will be 58.

If this function is used in the frontend or is manually called by any user, will either prevent users from opening any position using the right amount of ARCH needed (or having to calculate it using less intuitive methods) or force them to send more ARCH than needed to open the position.

In addition to this, and if calculateArchNeededForLeverage() is used in createLeveragedPosition() logic in the future, this behavior may allow users opening positions without having to pay any ARCH if the result of the division is lower than 1.

This finding would usually be classified as Medium, but, since extra ARCH tokens are not returned as described in HAL-04, the impact of this finding has been raised.

Code Location

ParameterStore.sol

    function calculateArchNeededForLeverage(uint256 leverageAmount) public view returns (uint256) {
        return (leverageAmount / _archToLevRatio) * 1 ether;
    }

A new position will be opened by user1, depositing 10 OUSD for 10 cycles. The amount of ARCH needed for opening the position, according to the output of calculateArchNeededForLeverage is 58, which is the amount the user will be supplied. Since the calculation has been rounded down, it will not create the position, since not enough ARCH has been provided. If supplying 100 ARCH instead, the position will be created successfully.

archCalculation.png
Score
Impact: 5
Likelihood: 3

8.6 CONTRACT DEPENDENCIES CANNOT BE MODIFIED

// High

Description

Every main contract has a setDependencies() function, which is used to set the key contracts needed for the proper functioning of the entire ecosystem, such as tokens, pools, or other contract addresses, or modify them after the deployment if needed for operational purposes.

In addition to that, Coordinator.sol and PoolManager.sol contracts include safeApprove() and approve() calls, respectively.

Due to these calls and the implementation of safeApprove() and 3CRV token's approve() function, which prevents new safeApprove() or approve() calls if the current allowance is greater than zero, setDependencies() could remain blocked, preventing contract administrators to modify these values if needed.

Code Location

PoolManager.sol

    function setDependencies(
        address addressParameterStore,
        address addressCoordinator,
        address addressLvUSD,
        address address3CRV,
        address addressPoolLvUSD3CRV
    ) external nonReentrant onlyAdmin {
        // Set variables
        _addressParameterStore = addressParameterStore;
        _addressCoordinator = addressCoordinator;
        _addressPoolLvUSD3CRV = addressPoolLvUSD3CRV;

        // Load contracts
        _paramStore = ParameterStore(addressParameterStore);
        _lvusd = IERC20Upgradeable(addressLvUSD);
        _crv3 = IERC20Upgradeable(address3CRV);
        _poolLvUSD3CRV = ICurveFiCurve(addressPoolLvUSD3CRV);

        _lvusd.approve(_addressPoolLvUSD3CRV, type(uint256).max);
        _crv3.approve(_addressPoolLvUSD3CRV, type(uint256).max);
    }

PoolManager.sol calls 3CRV.approve() each time setDependencies() is called. Since 3CRV's implementation of approve() does not allow new approvals when there is already an existing one, every setDependencies() call after the first one will be reverted.

Calling setDependencies() more than once on PoolManager.sol and Coordinator.sol contracts will easily disclose the problem:

approveFail1.png approveFail2.png
Score
Impact: 5
Likelihood: 3

8.7 LACK OF PARAMETER PRECISION

// Medium

Description

It has been detected that some parameters used in the different contracts are set and used in a way that lets the minimum settable value at 1%.

Code Location

ParameterStore.sol

        _maxNumberOfCycles = 10;
        _originationFeeRate = 5 ether / 100;
        _globalCollateralRate = 90;
        _rebaseFeeRate = 10 ether / 100; // meaning 10%
        _treasuryAddress;
        _curveGuardPercentage = 90;
        _slippage = 2; // 2%;
        _archToLevRatio = 1 ether; // meaning 1 arch is equal 1 lvUSD
        _curveMaxExchangeGuard = 50; // meaning we allow exchange with get 50% more then we expected
        _treasuryAddress = address(0);

For example, _slippage is set to 2, implying that a maximum slippage of 2% is allowed. However, if this value needs to be modified in the future or if user-controlled slippage is implemented, it would only accept integer values, such as 0%, 1%, 2%, etc.

Score
Impact: 2
Likelihood: 5

8.8 MISLEADING REVERT MESSAGES

// Medium

Description

When a require condition is not satisfied, an error message is usually included in the response, giving information about the reason for the revert.

However, it has been detected that some of the error messages displayed when certain conditions are not met provide an incorrect explanation for the revert. These inaccurate messages could hinder the identification of the cause of the errors to contract administrators and could lead to contract malfunctions if managing decisions are taken after these error messages.

Code Location

Four different functions have been implemented for exchanging LvUSD, OUSD, and 3CRV pools between Curve pools. However, the error message LvUSD pool too imbalanced. is displayed on _xLvUSDfor3CRV, _x3CRVforLvUSD, and _x3CRVforOUSD, but OUSD pool too imbalanced. should be displayed instead in the last function.

Exchanger.sol

function _xOUSDfor3CRV(uint256 amountOUSD) internal returns (uint256 amount3CRV) {
        /**
         * @param _expected3CRV uses get_dy() to estimate amount the exchange will give us
         * @param _minimum3CRV minimum accounting for slippage. (_expected3CRV * slippage)
         * @param _returned3CRV amount we actually get from the pool
         * @param _guard3CRV sanity check to protect user
         */
        uint256 _expected3CRV;
        uint256 _minimum3CRV;
        uint256 _returned3CRV;
        uint256 _guard3CRV = (amountOUSD * _paramStore.getCurveGuardPercentage()) / 100;

        // Verify Exchanger has enough OUSD to use
        require(amountOUSD <= _ousd.balanceOf(address(this)), "Insufficient OUSD in Exchanger.");

        // Estimate expected amount of 3CRV
        // get_dy(indexCoinSend, indexCoinRec, amount)
        _expected3CRV = _poolOUSD3CRV.get_dy(0, 1, amountOUSD);

        // Set minimum required accounting for slippage
        _minimum3CRV = (_expected3CRV * (100 - _paramStore.getSlippage())) / 100;

        // Make sure pool isn't too bent
        // TODO allow user to override this protection
        // TODO auto balance if pool is bent
        require(_minimum3CRV >= _guard3CRV, "OUSD pool too imbalanced.");

        // Increase allowance
        _ousd.safeIncreaseAllowance(address(_poolOUSD3CRV), amountOUSD);

        // Exchange OUSD for 3CRV:
        _returned3CRV = _poolOUSD3CRV.exchange(0, 1, amountOUSD, _minimum3CRV);

        // Set approval to zero for safety
        _ousd.safeApprove(address(_poolOUSD3CRV), 0);

        return _returned3CRV;
    }
Score
Impact: 4
Likelihood: 2

8.9 OWNER CAN RENOUNCE OWNERSHIP

// Medium

Description

The Owner of the contract is usually the account that deploys the contract. As a result, the Owner can perform some privileged functions (such as adding minters or set pools). In every smart contract in scope, ownership renounce functions can be used to renounce the Owner or ADMIN permission/role. Renouncing ownership before transferring would result in the contract having no Owner, eliminating the ability to call privileged functions.

If renounceOwnership() is called in ArchToken.sol, the ownership of the contract will be transferred to the zero address, rendering the functions containing the onlyOwner modifier unusable:

ownershipRenounce.png

Similar operations could be performed with revokeRole() or renounceRole() in LvUSDToken.sol, or with revokeAdmin() on any other contract in scope.

Score
Impact: 4
Likelihood: 2

8.10 LACK OF PAUSE FUNCTIONALITY

// Medium

Description

It has been observed that no pausing mechanism is implemented across the contracts. However, pausing mechanisms can be helpful in various situations, such as preventing users from interacting with specific functions under certain conditions (such as a contract upgrade or migration) or as a way to avoid fund loss to attackers in the eventuality of a security breach.

Score
Impact: 4
Likelihood: 2

8.11 LACK OF PARAMETER LIMITS

// Medium

Description

It has been detected that some parameter-modifying functions do not have logical limits. This may cause the contract to function with parameter values that, although allowed, make no sense in the application context, which might cause various problems or even render the contract unusable.

Code Location

ParameterStore.sol

    function changeOriginationFeeRate(uint256 newFeeRate) external onlyGovernor {
        emit ParameterChange("originationFeeRate", newFeeRate, _originationFeeRate);
        _originationFeeRate = newFeeRate;
    }

This function's lack of logical limits allows the fee charged on every opened position to be set at any % desired. So, for example, setting it to 0 will mean that no fee is charged on every position opened while setting it to 1 ether will mean that 100% of the OUSD swapped for the borrowed LvUSD will be transferred to the treasury, making the position unwindable since the principle deposited will never be enough to pay for the borrowed LvUSD (unless the position is opened for only 1 cycle).

Greater values will stop the position opening functionality since it will try to transfer more OUSD to the treasury in the concept of fees than the OUSD amount available.

Coordinator.sol

    function _takeOriginationFee(uint256 _leveragedOUSDAmount) internal returns (uint256 fee) {
        uint256 _fee = _paramStore.calculateOriginationFee(_leveragedOUSDAmount);
        _ousd.safeTransfer(_paramStore.getTreasuryAddress(), _fee);
        return _fee;
    }

In addition, changeMaxNumberOfCycles(), changeArchToLevRatio(), and changeCurveMaxExchangeGuard() also lack from parameter limits.

Score
Impact: 4
Likelihood: 2

8.12 ZERO LEVERAGE POSITIONS CAN BE OPENED

// Medium

Description

It has been detected that positions with 0 leverage cycles can be opened. Although these kinds of positions make no sense in the context of these contracts, they still can be opened (i.e., because of user input error).

Opening a position like this will have the following consequences:

  • Any ARCH sent with the position will be burned and lost forever (as described in the UNUSED ARCH NOT RETURNED WHEN OPENING A POSITION finding).
  • No fees will be collected since opening fees are calculated from the leverage obtained, which will be zero in this scenario.
  • If a USD rebase takes place after opening the position, the position will remain locked forever. This is caused by the behavior described in the OUSDTOTAL VARIABLE SHOULD BE REMOVED finding. When trying to withdraw any OUSD under the position, oUSDTotal will remain the same amount as the initial OUSD sent as principal, and oUSDAmountToWithdraw will be greater because of the rebase, causing the following required statement never to be fulfilled:

CDPosition.sol

    function withdrawOUSDFromPosition(uint256 nftID, uint256 oUSDAmountToWithdraw) external nftIDMustExist(nftID) nonReentrant onlyExecutive {
        require(_nftCDP[nftID].oUSDTotal >= oUSDAmountToWithdraw, "Insufficient OUSD balance");
        _nftCDP[nftID].oUSDTotal -= oUSDAmountToWithdraw;
    }
Score
Impact: 5
Likelihood: 1

8.13 OUSDTOTAL VARIABLE SHOULD BE REMOVED

// Low

Description

Struct CDP is defined within CDPosition.sol contract. It contains information about positions, such as the initial LvUSD amount borrowed, the OUSD shares held by the position, etc.

However, it has been detected that oUSDTotal is declared, used, and never updated, which could cause unpredictable behavior or even render positions unwindable.

According to the comments in the code, this variable holds the total OUSD amount held by the position, including the initial OUSD amount staked (principle), the leverage obtained, and the profits obtained by interest or rebases. This information could also be retrieved using CDP.shares.

However, this variable is never updated, so the oUSDTotal value will be stuck forever with the principle + leverage OUSD amount. Also, this variable is incorrectly used in a require statement to assert that no more OUSD than the available is used:

CDPosition.sol

    function withdrawOUSDFromPosition(uint256 nftID, uint256 oUSDAmountToWithdraw) external nftIDMustExist(nftID) nonReentrant onlyExecutive {
        require(_nftCDP[nftID].oUSDTotal >= oUSDAmountToWithdraw, "Insufficient OUSD balance");
        _nftCDP[nftID].oUSDTotal -= oUSDAmountToWithdraw;
    }

When following the function logic flow, it has been detected that oUSDTotal is compared to oUSDAmountToWithdraw, that is, the OUSD amount remaining under the position after swapping OUSD for LvUSD to repay the leverage obtained. After the comparison, oUSDAmountToWithdraw is subtracted from oUSDTotal, leaving its value inconsistent.

Code Location

CDPosition.sol

    struct CDP {
        uint256 oUSDPrinciple; // Amount of OUSD originally deposited by user
        uint256 oUSDInterestEarned; // Total interest earned (and rebased) so far
        uint256 oUSDTotal; // Principle + OUSD acquired from selling borrowed lvUSD + Interest earned
        uint256 lvUSDBorrowed; // Total lvUSD borrowed under this position
        uint256 shares; // Total vault shares allocated to this position
    }
Score
Impact: 3
Likelihood: 1

8.14 INCONSISTENT PARAMETER FORMATTING

// Low

Description

ParameterStore.sol contract stores global parameters that are used when managing positions, such as _maxNumberOfCycles, _originationFeeRate, etc.

However, it has been detected that parameters with a similar format or that are used in similar ways (like percentages, for example) are defined and used differently, which could cause contract unexpected behavior (or even worse situations such as fund loss) if they are mistakenly modified.

Code Location

ParameterStore.sol

        _maxNumberOfCycles = 10;
        _originationFeeRate = 5 ether / 100;
        _globalCollateralRate = 90;
        _rebaseFeeRate = 10 ether / 100; // meaning 10%
        _treasuryAddress;
        _curveGuardPercentage = 90;
        _slippage = 2; // 2%;
        _archToLevRatio = 1 ether; // meaning 1 arch is equal 1 lvUSD
        _curveMaxExchangeGuard = 50; // meaning we allow exchange with get 50% more then we expected
        _treasuryAddress = address(0);

In this example can be seen how two different rates (with 90% and 10% values, respectively) are calculated differently.

Score
Impact: 3
Likelihood: 1

8.15 LACK OF TRANSFEROWNERSHIP PATTERN

// Informational

Description
Finding description placeholder
Score
Impact:
Likelihood:

8.16 UNPROTECTED GLOBALCOLLATERALRATE PARAMETER

// Informational

Description
Finding description placeholder
Score
Impact:
Likelihood:

8.17 POSITIONS CANNOT BE UNWOUND WHEN TRANSFERRED

// Informational

Description

Once any user opens a position, an NFT containing the relative information gets minted to the user. This NFT grants the position ownership and can be burned (if the position is withdrawn) or sold for a profit.

When the NFT is minted using safeMint(), _setApprovalForAll() also gets called, approving the address with EXECUTIVE role in the NFT contract (ideally LeverageEngine contract) to burn or transfer the NFT (needed for burning the NFT once the position gets unwound).

However, it has been detected that, although these NFTs can be easily transferred by calling PositionToken.transferFrom(), the LeverageEngine contract will not be approved to interact with the NFT anymore since the owner is different.

This causes the position to remain locked until one of the two following conditions is met:

  • A new approval to LeverageEngine gets issued by the new owner.
  • The new owner opens another position, called _setApprovalForAll(), approving LeverageEngine to interact with every NFT owned by the new user.
Score
Impact: 1
Likelihood: 2

8.18 UNUSED CODE

// Informational

Description

Unused pieces of code such as modifiers or variables have been found through the code. These unused variables increase gas costs for contract deploying and interactions, impact code readability and might cause the contract to behave in unexpected ways or introduce new vulnerabilities if these variables are mistakenly used.

Code Location

LeverageEngine.sol

    uint256 internal _positionId;

This variable is not used anywhere in the code.

CDPosition.sol

    struct CDP {
        uint256 oUSDPrinciple; // Amount of OUSD originally deposited by user
        uint256 oUSDInterestEarned; // Total interest earned (and rebased) so far
        uint256 oUSDTotal; // Principle + OUSD acquired from selling borrowed lvUSD + Interest earned
        uint256 lvUSDBorrowed; // Total lvUSD borrowed under this position
        uint256 shares; // Total vault shares allocated to this position
    }

oUSDInterestEarned is declared within CDP struct, but it is not used anywhere in the code.

    modifier notImplementedYet() {
        revert("Method not implemented yet");
        _;
    }

This modifier is not used anywhere.

Score
Impact: 1
Likelihood: 1

8.19 REDUNDANT STATEMENTS

// Informational

Description

Redundant statements have been found through the code. These statements increase gas costs for contract deploying and interactions, impact code readability.

Code Location

In this example, _treasuryAddress is declared twice but no actual value is assigned:

ParameterStore.sol

    address internal _treasuryAddress;
    uint256 internal _curveGuardPercentage; // in regular (0-100) percentages
    uint256 internal _slippage; // in regular (0-100) percentages
    /// example for _archToLevRatio: If each arch is worth 1000 lvUSD, set this to 1000
    uint256 internal _archToLevRatio;
    // maximum allowed "extra" tokens when exchanging
    uint256 internal _curveMaxExchangeGuard;

    event ParameterChange(string indexed _name, uint256 _newValue, uint256 _oldValue);
    event TreasuryChange(address indexed _newValue, address indexed _oldValue);

    function initialize() public initializer {
        _grantRole(ADMIN_ROLE, _msgSender());
        setGovernor(_msgSender());
        setExecutive(_msgSender());
        setGuardian(_msgSender());

        _maxNumberOfCycles = 10;
        _originationFeeRate = 5 ether / 100;
        _globalCollateralRate = 90;
        _rebaseFeeRate = 10 ether / 100; // meaning 10%
        _treasuryAddress;
        _curveGuardPercentage = 90;
        _slippage = 2; // 2%;
        _archToLevRatio = 1 ether; // meaning 1 arch is equal 1 lvUSD
        _curveMaxExchangeGuard = 50; // meaning we allow exchange with get 50% more then we expected
        _treasuryAddress = address(0);
    }
Score
Impact: 1
Likelihood: 1

8.20 MISLEADING CODE COMMENTS

// Informational

Description

While comments are useful for understanding true purpose and functionality of the code, misleading comments have been detected that do not match with the actual implementation in the code.

Code Location

In this instance, comments describe a 3% slippage and fee overrun, but it is previously fixed at 1%.

Exchanger.sol

    function _swapOUSDforLvUSD(uint256 amountOUSD, uint256 minRequiredLvUSD) internal returns (uint256 lvUSDReturned, uint256 remainingOUSD) {
        // Estimate "neededOUSD" using get_dy()
        uint256 _needed3CRV = _poolLvUSD3CRV.get_dy(_indexLvUSD, _index3CRV, minRequiredLvUSD);
        uint256 _neededOUSD = _poolOUSD3CRV.get_dy(_index3CRV, _indexOUSD, _needed3CRV);
        uint256 _neededOUSDWithSlippage = (_neededOUSD * 101) / 100;

        require(amountOUSD >= _neededOUSDWithSlippage, "Not enough OUSD for exchange");

        // We lose some $ from fees and slippage
        // multiply _neededOUSD * 103%
        uint256 _returned3CRV = _xOUSDfor3CRV(_neededOUSDWithSlippage);

        uint256 _returnedLvUSD = _x3CRVforLvUSD(_returned3CRV);
        require(_returnedLvUSD >= minRequiredLvUSD, "Not enough LvUSD in pool");

        // calculate remaining OUSD
        remainingOUSD = amountOUSD - _neededOUSDWithSlippage;
        _ousd.safeTransfer(_addressCoordinator, remainingOUSD);

        // send all swapped lvUSD to coordinator
        _lvusd.safeTransfer(_addressCoordinator, _returnedLvUSD);

        return (_returnedLvUSD, remainingOUSD);
    }
Score
Impact: 1
Likelihood: 1

8.21 FEE RATES DIFFERS FROM WHITEPAPER

// Informational

Description
Finding description placeholder
Score
Impact:
Likelihood:

8.22 USE ++I INSTEAD OF I++ IN LOOPS FOR GAS OPTIMIZATION

// Informational

Description

In the loop within getAllowedLeverageForPosition() function in ParameterStore.sol contract, the variable i is incremented using i++. It is known that, in loops, using ++i costs less gas per iteration than i++. This also affects variables incremented inside the loop code block.

Code Location

ParameterStore.sol

    function getAllowedLeverageForPosition(uint256 principle, uint256 numberOfCycles) public view returns (uint256) {
        require(numberOfCycles <= _maxNumberOfCycles, "Cycles greater than max allowed");
        uint256 leverageAmount = 0;
        uint256 cyclePrinciple = principle;
        for (uint256 i = 0; i < numberOfCycles; i++) {
            cyclePrinciple = (cyclePrinciple * _globalCollateralRate) / 100;
            leverageAmount += cyclePrinciple;
        }
        return leverageAmount;
    }
Score
Impact: 1
Likelihood: 1

8.23 UNDEPLOYABLE CONTRACTS

// Informational

Description

Multiple contracts on scope exceed 24576 bytes contract size limit, and could not be effectively deployed on the mainnet.

Code Location

  • VaultOUSD.sol: 26656 bytes
  • Exchanger.sol: 25194 bytes
Score
Impact: 1
Likelihood: 1

8.24 UNUSED IMPORTS

// Informational

Description

Many contracts in scope imports console.sol contract but none of its functions are used anywhere, with the exception of Exchanger.sol.

Score
Impact: 1
Likelihood: 1

8.25 VAULTS NEED TO ENABLE OUSD REBASES

// Informational

Description

By default, any EOA holding any OUSD balance will be automatically enrolled in the token's rebase feature, meaning that their balance will be increased whenever the token is rebased without having to perform any additional action.

However, this is not true for any multi-sig wallet or any other kind of smart contract, needing to enroll manually by calling OUSD's rebaseOptIn() function. Otherwise, no OUSD yield will be earned.

Score
Impact: 1
Likelihood: 1

8.26 UNINITIALIZED CONTRACTS

// Informational

Description

Although set in place, contracts such as ReentrancyGuardUpgradeable, UUPSUpgradeable, and AccessControlUpgradeable are not initialized in any of the smart contracts in which they are being used.

Although the current implementation allows these contracts to properly function without being initialized, future upgrades might not behave the same way, creating new vulnerabilities or even rendering the contract unusable.

Score
Impact: 1
Likelihood: 1

8.27 FEE-ON-TRANSFER TOKENS NOT SUPPORTED

// Informational

Description

Certain ERC20 tokens may modify token balances or amounts used in transfer(), transferFrom(), and balanceOf() calls. One of these tokens are deflationary tokens, that charge a certain fee for every transfer()`` ortransferFrom()``. During the code review, it has been detected that the protocol does not support Fee-on-transfer tokens, due mainly to the incompatibilities and balance mismatches that would cause every transaction to revert.

Code Location

Multiple of these mismatches have been already commented on findings above, such as POSITIONS CANNOT BE UNWOUND DUE TO OUSD BEHAVIOR finding.

Score
Impact: 1
Likelihood: 1

8.28 UNCHECKED TRANSFER

// Informational

Description

Several tokens do not revert in case of failure and return false. If that happened, Treasury would not receive the burned ARCH and any user could open positions without having to pay any ARCH.

Although ARCH token implementation currently reverts on failure, it is still considered a best practice to check the return value of any external token interaction. This is being currently done for every interaction with external tokens (such as LvUSD, 3CRV, or OUSD) by using SafeERC20Upgradeable, but not with ARCH. This may lead to contract malfunctions if ARCH implementation changes in a future or during the development phase.

Score
Impact: 1
Likelihood: 1

8.29 UNUSED RETURN

// Informational

Description

As described in finding CONTRACT DEPENDENCIES CANNOT BE MODIFIED, when setDependencies() function from PoolManager.sol is called, also two approve() calls of LvUSD and 3CRV are performed. However, unlike the rest of the contracts, approve() is used, instead of safeApprove(), safeIncreaseAllowance(), or safeDecreaseAllowance().

As also described in UNCHECKED TRANSFER finding, checking the return value of these kind of transfers is considered a good practice, and could prevent contract malfunctions in case of approval failures if non revert on fail tokens are in use.

Score
Impact: 1
Likelihood: 1

8.30 HARDCODED SLIPPAGE

// Informational

Description

In most of the interactions with pools (such as swaps or liquidity providings), slippage % is somehow adjustable (since _slippage parameter from ParameterStore.sol contract is used).

However, in some interactions, such as when providing liquidity using PoolManager.sol's fundPoolWith3CRV(), slippage % is hardcoded, and used as _min_mint_amountvalue.

Code Location

PoolManager.sol

    function fundPoolWith3CRV(address buyerAddress, uint256 amoutToFundInLvUSD) external nonReentrant onlyAdmin returns (uint256) {
        /// Method assumes that this contract , has allowance to spend buyerAddress 3CRV tokens
        /// Method assumes that this contract, has allowance to spend Coordinator lvUSD tokens
        require(_lvusd.balanceOf(_addressCoordinator) > amoutToFundInLvUSD, "Insufficient lvUSD on Coord");
        // // Transfer lvUSD and 3CRV to this contract
        _lvusd.safeTransferFrom(_addressCoordinator, address(this), amoutToFundInLvUSD);
        _crv3.safeTransferFrom(buyerAddress, address(this), amoutToFundInLvUSD);
        uint256[2] memory amounts = [amoutToFundInLvUSD, amoutToFundInLvUSD];
        uint256 expectedTokenAmountToGet = (_poolLvUSD3CRV.calc_token_amount(amounts, true) * 99) / 100;
        return _poolLvUSD3CRV.add_liquidity(amounts, expectedTokenAmountToGet, buyerAddress);
    }

Exchanger.sol

function _swapOUSDforLvUSD(uint256 amountOUSD, uint256 minRequiredLvUSD) internal returns (uint256 lvUSDReturned, uint256 remainingOUSD) {
        // Estimate "neededOUSD" using get_dy()
        uint256 _needed3CRV = _poolLvUSD3CRV.get_dy(_indexLvUSD, _index3CRV, minRequiredLvUSD);
        uint256 _neededOUSD = _poolOUSD3CRV.get_dy(_index3CRV, _indexOUSD, _needed3CRV);
        uint256 _neededOUSDWithSlippage = (_neededOUSD * 101) / 100;

        require(amountOUSD >= _neededOUSDWithSlippage, "Not enough OUSD for exchange");

        // We lose some $ from fees and slippage
        // multiply _neededOUSD * 103%
        uint256 _returned3CRV = _xOUSDfor3CRV(_neededOUSDWithSlippage);

        uint256 _returnedLvUSD = _x3CRVforLvUSD(_returned3CRV);
        require(_returnedLvUSD >= minRequiredLvUSD, "Not enough LvUSD in pool");

        // calculate remaining OUSD
        remainingOUSD = amountOUSD - _neededOUSDWithSlippage;
        _ousd.safeTransfer(_addressCoordinator, remainingOUSD);

        // send all swapped lvUSD to coordinator
        _lvusd.safeTransfer(_addressCoordinator, _returnedLvUSD);

        return (_returnedLvUSD, remainingOUSD);
    }
Score
Impact: 1
Likelihood: 1

8.31 UNUSED FUNCTIONS

// Informational

Description

Some unused functions have been declared in Exchanger.sol contract. Although they are declared as external, they are also declared with the 'OnlyExecutivemodifier, which restricts the usage of these functions only to the address havingExecutiverole, which can only be assigned to one address at a time, and it is supposed to beCoordinator.sol` contract.

However, no calls to these functions have been detected in Coordinator.sol, making these functions useless.

Code Location

PoolManager.sol

function xLvUSDfor3CRV(uint256 amountLvUSD) external nonReentrant onlyExecutive returns (uint256) {
        return _xLvUSDfor3CRV(amountLvUSD);
    }

    function x3CRVforOUSD(uint256 amount3CRV) external nonReentrant onlyExecutive returns (uint256) {
        return _x3CRVforOUSD(amount3CRV);
    }

    function xOUSDfor3CRV(uint256 amountOUSD) external nonReentrant onlyExecutive returns (uint256) {
        return _xOUSDfor3CRV(amountOUSD);
    }

    function x3CRVforLvUSD(uint256 amount3CRV) external nonReentrant onlyExecutive returns (uint256) {
        return _x3CRVforLvUSD(amount3CRV);
    }
Score
Impact: 1
Likelihood: 1

8.32 POSSIBLE MISUSE OF PUBLIC FUNCTIONS

// Informational

Description

In public functions, array arguments are immediately copied to memory, while external functions can read directly from calldata. Reading calldata is cheaper than memory allocation. Public functions need to write the arguments to memory because public functions may be called internally. Internal calls are passed internally by pointers to memory. Thus, the function expects its arguments being located in memory when the compiler generates the code for an internal function.

Code Location

Consider marking below functions as external instead of public if they will never be directly called within the same contract or in any of their descendants:

\textbf{\underline{AccessController.sol}:} addMinter, revokeAdmin, getAddressGovernor, getAddressGuardian

\textbf{\underline{ParameterStore.sol}:} getTreasuryAddress, getCurveGuardPercentage, getSlippage, getArchToLevRatio, getAllowedLeverageForPositionWithArch, calculateOriginationFee, calculateArchNeededForLeverage

Score
Impact: 1
Likelihood: 1

9. Automated Testing

STATIC ANALYSIS REPORT

Description

Halborn used automated testing techniques to enhance coverage of certain areas of the scoped contract. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified all the contracts in the repository and was able to compile them correctly into their ABI and binary formats. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.

Slither Results

  • AccessController.sol

Access1.pngAccess2.pngAccess3.png
  • ArchToken.sol

Arch1.pngArch2.png
  • CDPosition.sol

CDP1.pngCDP2.pngCDP3.png
  • Coordinator.sol

Coord1.pngCoord2.pngCoord3.png
  • Exchanger.sol

Ex1.pngEx2.pngEx3.pngEx4.pngEx5.png
  • LeverageEngine.sol

Leverage1.pngLeverage2.pngLeverage3.pngLeverage4.pngLeverage5.pngLeverage6.pngLeverage7.png
  • LvUSDToken.sol

Lv1.pngLv2.png
  • ParameterStore.sol

Param1.pngParam2.pngParam3.png
  • PoolManager.sol

Pool1.pngPool2.pngPool3.png
  • PositionToken.sol

Pos1.pngPos2.pngPos3.png
  • VaultOUSD.sol

Vault1.pngVault2.pngVault3.pngVault4.pngVault5.pngVault6.pngVault7.png

Issues found by slither are either reported above or false positives.

AUTOMATED SECURITY SCAN

MYTHX

Halborn used automated security scanners to assist with detecting well-known security issues and to identify low-hanging fruits on the targets for this engagement. MythX, a security analysis service for Ethereum smart contracts, is among the tools used. MythX was used to scan all the contracts and sent the compiled results to the analyzers to locate any vulnerabilities.

Results

  • AccessController.sol

No relevant results were returned.

  • ArchToken.sol

No relevant results were returned.

  • CDPosition.sol

No relevant results were returned.

  • Coordinator.sol

No relevant results were returned.

  • Exchanger.sol

No relevant results were returned.

  • LeverageEngine.sol

No relevant results were returned.

  • LvUSDToken.sol

No relevant results were returned.

  • ParameterStore.sol

No relevant results were returned.

  • PoolManager.sol

No relevant results were returned.

  • PositionToken.sol

No relevant results were returned.

  • VaultOUSD.sol

No relevant results were returned.

Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.

// Download the full report

* Use Google Chrome for best results

** Check "Background Graphics" in the print settings if needed