Quantstamp reviewed Secured Finance on 12 Dec 2023: 45 findings, 0 critical; 23 marked fixed, 17 acknowledged, 5 open.
Report hosted on certificate.quantstamp.com.
Trustblock does not review code.
Statuses are the security firm’s own, from its report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 4 |
| Medium | 1 | 3 | 6 |
| Low | 4 | 14 | 13 |
Several contracts use a list of names (`acceptedContracts`) as well as a modifier `onlyAcceptedContracts` to restrict some of their functions to only a subset of addresses. The contract `TokenVault` that "accepts" the contracts `LendingMarketController` and `ReserveFund`. In parallel, the contract `ReserveFund` inherits the contract `MixinWallet`, which lets the owner of that contract execute arbitrary calls to arbitrary addresses with arbitrary payloads via the functions `MixinWallet.executeTransaction()` and `MixinWallet.executeTransactions()`. As a consequence, the owner of `ReserveFund` can execute the following restricted functions of the contract `TokenVault`: * `depositFrom()` where any available amount of token allowed to be spent by the protocol can be transferred from users' wallets to the protocol; * `addDepositAmount()` where the deposit balance of a registered currency of any user can be increased by any amount; * `removeDepositAmount()` where the deposit balance of a registered currency of any user can be decreased by any available amount; * `executeForcedReset()` where the deposit balance of a registered currency of any user can be set to 0; * `transferFrom()` where the deposit balance of a registered currency of any user can be transferred to any other user; After executing the exploit scenario described below, one address with a non-zero balance can withdraw funds from the system via the function `withdraw()`. The root cause of this issue is the fact that the access control mechanism based on a list of `acceptedContracts` does not respect the Principle of Least Privilege. Several contracts requiring access to restricted functions of a third contract for separate use cases both obtain access to all restricted functions of that third contract.
`ReserveFund` contract implements a pausing system through a boolean variable `paused` and a public function `isPaused()` that returns it. Functions `pause()` and `unpause()` toggle `paused` to `true` and `false`. Functions `pause()` and `unpause()` are both unprotected by any access modifiers. This allows an attacker to pause and unpause the `ReserveFund` contract as they please. Whether the contract is paused is checked by `LiquidationLogic.executeLiquidation()` and depending on the result, different calculations are made to cover insolvent positions, which may allow an attacker to force improper calculations while their positions are being liquidated by front-running the liquidation transaction to pause/unpause the contract.
A liquidator may be able to repay a position either in a token that used to be accepted as collateral by the protocol or a token that used to be accepted for trading. When a liquidator calls `executeForcedRepayment()`, the function attempts forced repayment on behalf of a user if the user defaulted on a borrowing position. However, the function performs no validation on the supplied `_collateralCcy`, allowing repayment to take place even if the collateral currency is no longer accepted by the protocol, whether as collateral or as a trading token. This is possible because the `TokenVault` is responsible for validating whether a currency is allowed as collateral. However, when `LiquidationLogic._transferCollateral()` calls `TokenVault.transferFrom()`, which is protected by the `onlyRegisteredCurrency` modifier, the check in `isRegisteredCurrency()` only validates that `Storage.slot().tokenAddresses[_ccy] != address(0)`. This will irreversibly be true for any currency that is added via `TokenVault.registerCurrency()` since the function call adds the passed address to the `tokenAddresses` mapping, however calling `TokenVault.updateCurrency()` will only remove the `_ccy` entry from `collateralCurrencies`. Additionally, during the function's execution, it calls `CurrencyController.convert()` to get the `liquidationAmountInCollateralCcy`. The function call then lands on `CurrencyController._getAggregatedLastPrice()`, which will return `1` if the currency has no pricefeeds or the actual price of the currency in case it was previously accepted as collateral (see `Haircut and pricefeed data not erased when a currency is removed` for more context).
The Secured Finance protocol implements a lazy evaluation logic, where certain data structures are left unupdated after specific operations to mitigate gas costs. Notably, in the execution of an order, the array containing a user's active (unfilled) orders for a specific order book is not updated within the transaction. This array is only updated in the subsequent transaction before any user-related operations. The critical function at the center of this issue is `LendingMarket.cleanUpOrders()`. This function calls `OrderActionLogic.cleanUpOrders()`, responsible for updating the unfilled orders of a user for a given order book, specifically `orderbook.activeLendOrderIds[user]` and `orderbook.activeBorrowOrderIds[user]`. This function edits these arrays and expects that the caller will update the removed funds. If not, these funds are not moved and will be lost. The function `FundManagementLogic._cleanUpOrders()` shows how the different vaults should be updated with the amounts returned by `LendingMarket.cleanUpOrders()`. Importantly, this function lacks protection, allowing anyone to call it. While it successfully updates those arrays based on the order book trees, the funds filled of the removed orders from the array are not appropriately tracked or accounted for.
Each time an order is executed, its unit price is saved in the contract. This will be used to calculate the auto-roll price for the next market when the current market is rotated. The function `updateOrderLogs()` is called when an order is executed (at the moment of updating the taker's balance). In a liquid and active market, it will save the present and future value of all orders in the last 6 hours of the market (observation period, starting 6 hours before maturity). This way, the volume-weighted average unit price can be calculated later. However, to protect against the scenario of a market with no order executions in this observation period, the system also saves the `estimatedAutoRollUnitPrice`: the last executed price (converted to the new maturity). This price is recorded without verification: a small order of a few wei can set the next auto-rolling price. The system also implements data structures to save the weighted average unit price on a block, more secure and precise as it performs additional checks like a minimum reliable amount (minimum amount traded in a block to avoid price manipulation).
The `LendingMarketOperationLogic.executeEmergencyTermination()` function loops over the currencies in the protocol and invokes `pauseLendingMarkets()` on each of them, which looks like the following: ``` function pauseLendingMarkets(bytes32 _ccy) public { ILendingMarket market = ILendingMarket(Storage.slot().lendingMarkets[_ccy]); market.pauseMarket(); } ``` However, this function call will revert if a currency has no market or if the market is already paused (`_pause()` has `whenNotPaused` modifier), reverting the emergency termination trigger.
During the Itayose call, we calculate the `totalSupply` of Future Value (FV) using the Present Value (PV) (total amount filled in Itayose pre-orders) and `openingPrice` calculated from them. Following the scenario below: If the offset pre-order has the following amount with an opening unit price of `9220`, a precision loss will happen. ``` A: 100 B: 200 ``` After the Itayose process, the total supply will be `325 = (300 * 10000) / 9220`, calculated from the opening price and the total filled amount from the pre-orders. But each PV of order becomes: ``` A: 108 = (100 * 10000) / 9220 B: 216 = (200 * 10000) / 9220 ``` <br> This total sum of orders' future value is `324`, so the precision loss is `1` (`totalSupply` was set to `325`). `isAllRemoved` will never be fulfilled in `FutureValueVault.reset()`, leading to never calling `GenesisValueVault.updateGenesisValueWithResidualAmount()`. <br> ``` isAllRemoved = Storage.slot().removedLendingSupply[maturity] == Storage.slot().totalSupply[maturity] && Storage.slot().removedBorrowingSupply[maturity] == Storage.slot().totalSupply[maturity]; ```
In case of an emergency, a termination mechanism can be executed by the owner of the contract `LendingMarketController` to cease the operations of the protocol. Once executed, this operation cannot be reverted. For each active currency, the last aggregated price returned by the oracle is stored in the mapping `marketTerminationPrices`. Values in this mapping have the number of decimals stored in the mapping `CurrencyController.decimalCaches[]`. Then, users can force a settlement of all lending and borrowing positions via the function `executeEmergencySettlement()`. Once the protocol is terminated, it is possible to update the values in the mapping `CurrencyController.decimalCaches[]`, which would affect the prices used during the settlement process and obtained via the functions `FundManagementLogic._convertToBaseCurrencyAtMarketTerminationPrice()` and `FundManagementLogic._convertFromBaseCurrencyAtMarketTerminationPrice()`.
Several functions in the contract `TokenVault` only accept to work for currencies registered in the mapping `tokenAddresses`, based on the modifier `onlyRegisteredCurrency()`. The owner of the contract can register new currencies via the function `registerCurrency()`. Only currencies with an existing identifier registered in the contract `CurrencyController` can be registered. However, the fact that an address matches a given currency identifier is not checked. Also, the association between a currency identifier and an address can be updated by calling twice the function `registerCurrency()` for the same identifier by a different address. If the owner of the contract gets compromised, the exploit scenario below becomes possible.
1. Addresses with the role `OPERATOR_ROLE` can pause and unpause `LendingMarkets` if required. According to the logic in the contract `MixinAccessControl`, addresses with the role `DEFAULT_ADMIN_ROLE` can add or remove operators via the functions `addOperator()` and `removeOperator()`. However, it is possible to temporarily remain without any address having the role `OPERATOR_ROLE`, which would make it impossible to pause a `LendingMarket` in case of emergency. 2. Addresses with the role `DEFAULT_ADMIN_ROLE` can assign or revoke the roles `OPERATOR_ROLE` and `DEFAULT_ADMIN_ROLE`. However, it is possible to remain forever without any address having the role `DEFAULT_ADMIN_ROLE`, which would make it impossible to update the current assignments of Operators.
"Dead" code refers to code that is never executed and hence makes no impact on the final result of running a program. Dead code raises a concern, since either the code is unnecessary or the necessary code's results were ignored. Here are some snippets of dead code found in the codebase: 1. `TokenVault` contract is an accepted contract in `LendingMarketController`. However, `LendingMarketController` does not implement any function restricted to `acceptedContracts()`, so `acceptedContracts()` can be refactored to return an empty array. 1. `OrderBookLib.calculateItayoseResult()`: if there are no matching orders, the function returns zero for all the variables (`L530`). `openingUnitPrice` is calculated before, but the value is not used, wasting gas in that calculation. 1. `FundManagementLogic.calculateTotalFundsInBaseCurrency.amounts[]` can be reduced to seven elements. 1. `OrderActionLogic._fillOrders()` updates `filledOrder.amount` with `_amount - vars.remainingAmount`. However, this will always return `filledOrder.amount`, as `vars.remainingAmount` was already subtracted. 1. `OrderReaderLogic.getTotalAmountFromBorrowOrders()` commented part in the second for loop. More recommendations are discussed in the "Adherence to Best Practices" section.
Calling the function `deposit()` for a currency `_ccy` that is not the contract representing the native token, with a non-zero `_amount` as well as a non-zero `msg.value` will result in a deposit of `_amount _ccy`, but the amount of native tokens `msg.value` will not be accounted by the system. This is caused by the first `if` condition in the function `_deposit()`.
`MixinAccessControl._setupInitialRoles()` calls `_setupRole()` and `_grantRole()`. Both functions have the same effect, as `_setupRole()` just calls `_grantRole()`. `_setupRole()` is being deprecated in `v5.0.0` of OpenZeppelin contracts, and its use is not recommended. See `_setupRole()` comment: ``` NOTE: This function is deprecated in favor of {_grantRole}. ```
In `GenesisValueVault._updateBalance()`, `_updateTotalSupply()` is called before updating the balance of the `ReserveFund` but it depends on the user balance passed as an argument, so there could be accounting issues.
The system gets prices for a given pair of tokens (A, B) (price of one token A in token B) by using functions of the contract `CurrencyController` that fetch data from Oracle price feeds implementing the interface `AggregatorV3Interface`. When no price feed exists to directly provide the price of A in the base currency used by the protocol, a sequence of price feeds is stored in a struct `CurrencyControllerStorage.PriceFeed` that has two attributes: * `AggregatorV3Interface[] instances` representing the sequence of price feeds to call consecutively; * `uint256 heartbeat` representing the update frequency of an oracle; In addition, the mapping `decimalsCaches` stores the sum of the decimals used by the instances of the price feed. A price feed can be updated via the function `updatePriceFeed()`. However, some aspects are not checked during this update: 1. A single `heartbeat` value is provided to apply for all items of `_priceFeeds`. However, these price feeds can have different heartbeat values, leading to possible reverts or stale data if the expected unique heartbeat does not match with the rest of the price feeds' heartbeats. Add individual heartbeat limits for each price feed. 2. No check makes sure that the value of `_decimals` matches the sum of the decimals of each item of `_priceFeeds` 3. No check makes sure that `heartbeat` is respected for the last price returned by the price feed 4. No check makes sure that the last item of `_priceFeeds` returns a price in base currency. A check could at least make sure that the number of decimals matches the number of decimals of the base currency 5. The `_updatePriceFeed()` function calls `latestRoundData()` on the provided price feeds and uses the returned price information without checking the `updatedAt` against a time limit. Perform the same checks on the `updatedAt` result value as in `_getAggregatedLastPrice()`.
Issue removed: false positive
In `OrderStatisticsTreeLib.insert()` there are 4 ways to pass the while loop: * case 1: `tree.root == EMPTY`, which means that the tree is empty; * case 2: we can reach a node of value “value” from `root`, and we exit the function with a return statement; * case 3: we cannot reach a node of value “value” from `root`. Here, we can have two situations: * 3.a: `value < cursor`; * 3.b: `value > cursor`; Then, we get from storage the node `nValue`, which is a node of value “value”. `nValue` can be: * case 4: empty; * case 5: non-empty, meaning it has been dropped previously; We should update `nValue` in case 4 and case 5, but doing it in case 4 would be redundant because it already has default values. It is the purpose of the if statement, to only do these 4 operations if we are in case 5, to save gas. <br> However, the line `(self.nodes[cursor].left != value && self.nodes[cursor].right != value)` is a tautology and does not only capture case 4 but rather captures cases 4 and 5, because subcases `self.nodes[cursor].left == value` and `self.nodes[cursor].right == value` would have been captured in the while loop, which is case 2.
The owner of the contract `CurrencyController` can remove the price feed of a given supported currency via the function `removePriceFeed()`. If the currency is still supported by the system, getting prices for that currency will not revert but will rather return the value of `1` wei. As a result, this use case could negatively impact the computation of position coverage.
Although the codebase makes use of hardcoded values declared in `Constants.sol`, there are some undocumented values in the codebase: - `CurrencyController._updateHaircut()`: `10000` - `LendingMarketOperationLogic.initializeLendingMarket()`: `36`
Every Solidity file specifies in the header a version number of the format `pragma solidity (^)0.8.*`. The caret (`^`) before the version number implies an unlocked pragma, meaning that the compiler will use the specified version and above, hence the term "unlocked". The pragma versions used throughout the codebase should not be unlocked and set to the same version to ensure that they are all compiled by the same compiler version.
Orderbooks are expected to be frozen after pre-order period, during the Itayose period: > *"One hour before the launch, the orderbook will be frozen, and users will not be able to take any action on the orderbook. This includes placing, amending or canceling orders."* However, this is not enforced anywhere in the code, and `LendingMarketController.cancelOrder()` can be called anytime as long as the maturity is valid and the caller is the maker of the existing order.
`DepositManagementLogic.getWithdrawableCollateral()` uses `Constants.PRICE_DIGIT` to do related computations. However, as it is operating with rates, `Constants.PRICE_DIGIT` should not be used.
In `DepositManagementLogic.getWithdrawableCollateral()` the formula defined in the comment does not match the formula implemented in code to calculate `maxWithdraw`.
The report names a repository or files. Open the report to see its scope.
Repository and files the report names (70)
236 projects reviewed · 332 reports · latest Oct 2026
See Quantstamp’s auditsSomething wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us