As of 25 Oct 2026, Secured Finance has 2 audit reports by 1 security firm on Trustblock; the latest is by Quantstamp, dated 29 Mar 2024. Those reports list no open critical findings.
Record last updated . It holds audits and findings, contracts and incidents up to Oct 2026. Trustblock does not review code.
Newest first
| Audit | Security firm | Date | Scope | Critical findings | Report |
|---|---|---|---|---|---|
| Secured Finance - Diff Audit | Security firm: Quantstamp | Date: | Scope: Repository at commit f1c6276 | No open critical findings | Open · certificate.quantstamp.com |
| Secured Finance | Security firm: Quantstamp | Date: | Scope: Repository at commit 92f4e85 | No open critical findings | Open · certificate.quantstamp.com |
Statuses are the security firm’s own, from each report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 4 |
| Medium | 1 | 3 | 8 |
| Low | 4 | 19 | 20 |
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.
Open the report`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.
Open the reportA 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).
Open the reportThe 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.
Open the reportThe function `LendingMarketOperationLogic.createZCToken()` is used to deploy two types of ZC Tokens: perpetual and non-perpetual. * Non-perpetual ZC Tokens for an underlying token `T` should have a number of decimals that match the amount of decimal of `T`. * Perpetual ZC Tokens for this same underlying token `T` should have a number of decimals that matches the number of decimals of the genesis value of `T`, which should have 18 additional decimals. However, all ZCTokens are deployed with 18 additional decimals to match the genesis value amount. As a result, users withdrawing non-perpetual ZC Tokens may think that they received 1e18 times less than expected. **Note:** This issue was raised by the client during the audit process.
Open the reportUsers have the capability to withdraw a specified `_amount` of ZC tokens using the `_withdrawZCToken()` and `_withdrawZCPerpetualToken()` functions. However, the presence of the `isAll` flag, as determined by the `_getWithdrawableZCTokenAmount()` function, indicates that users can withdraw their entire position for a specific maturity. This functionality effectively eliminates the option for partial withdrawals. Specifically, based on the `isAll` value, either `0` or the `_amount` specified will be forwarded to the `lock()` function. The `lock()` function interprets an amount of `0` as an instruction to lock the user's total balance or the specified amount. Consequently, if `isAll` is set to `true` for a user intending to withdraw only `8` out of `10` tokens, all `10` tokens will be locked, not just the `8` requested. This could inadvertently impact the user's risk management strategies, as it differs from their initial withdrawal intent. **Note:** This issue was raised by the client during the audit process.
Open the reportEach 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).
Open the reportThe `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.
Open the reportDuring 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]; ```
Open the reportIn 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()`.
Open the reportSeveral 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.
Open the report1. 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.
Open the reportOrder book IDs are stored in `uint8` variables that hold a maximum value of 255. Each time a new order book is created, the previous ID will be incremented by 1. Given the expected rate of a new order book every 3 months, it will take approximately 63 years before new order books cannot be created due to overflow, effectively freezing the protocol. **Note:** This issue was raised by the client during the audit process.
Open the reportSome `ZCTokens` are expected to have as many as 36 decimals. While this does not create any issues within Secured Finance, third-party applications, such as wallets, can have issues supporting tokens with too many decimals. **Note:** This issue was raised by the client during the audit process.
Open the report"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. `_calculatePVFromFV()` is an overloaded function. One version takes two `uint256` as input and the other takes one `uint256` and one `int256`. The version that takes two `uint256` is not called.
Open the reportThe event `LiquidatorFeeRateUpdated` is defined as follows: ``` event LiquidatorFeeRateUpdated(uint256 previousRate, uint256 ratio); ``` However, when the value of `Storage.slot().liquidatorFeeRate` is updated in the function `_updateLiquidationConfiguration()`, the event will use for both fields the new value, instead of using the old and the new value: ``` if (_liquidatorFeeRate != Storage.slot().liquidatorFeeRate) { Storage.slot().liquidatorFeeRate = _liquidatorFeeRate; emit LiquidatorFeeRateUpdated(Storage.slot().liquidatorFeeRate, _liquidatorFeeRate); } ``` This can negatively impact external observers of the protocol.
Open the reportIt is important to validate inputs, even if they only come from trusted addresses, to avoid human error: In `MixinLiquidationConfiguration.sol`: 1. In the function `_updateLiquidationConfiguration()`, there is no check making sure that `fullLiquidationThresholdRate < liquidationThresholdRate`. In `CurrencyController.sol`: 1. In the function `_updatePriceFeed()`, there is no minimum and maximum check for the value of the heartbeats.
Open the reportIn the contract `LendingMarketController`, the modifier `ifValidMaturity()` contains the following line of code: ``` if (isValidMaturity(_ccy, _maturity)) revert InvalidMaturity(); ``` This line is a contradiction but still works because the function `isValidMaturity()` returns `true` if the maturity does not exist, and false if the maturity does exist. As a result, this adds confusion for external readers of the code.
Open the reportIn rare cases, the compound factor can become very large and cause an overflow when converting from genesis value to future value. This only happens in circumstances of high interest rates for extended periods such as 200% APR for 197 years. **Note:** This issue was raised by the client during the audit process.
Open the report"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.
Open the reportCalling 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()`.
Open the report`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}. ```
Open the reportIn `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.
Open the reportThe 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()`.
Open the reportIssue removed: false positive
Open the reportIn `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.
Open the reportThe 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.
Open the reportAlthough 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`
Open the reportEvery 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.
Open the reportOrderbooks 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.
Open the report`DepositManagementLogic.getWithdrawableCollateral()` uses `Constants.PRICE_DIGIT` to do related computations. However, as it is operating with rates, `Constants.PRICE_DIGIT` should not be used.
Open the reportIn `DepositManagementLogic.getWithdrawableCollateral()` the formula defined in the comment does not match the formula implemented in code to calculate `maxWithdraw`.
Open the reportThe reports name a repository or files. Open a report to see its scope.
Is this your project? Show this record on your own website. The label states what the record holds, links to this page and updates by itself. It is free and needs no sign-in.
Automatic: the three strongest facts of this project’s own record (its last audit and two more), highlighted. They update with the record. Picking a fact turns Automatic off.
<head><script src="https://widget.trustblock.run/project-widget.min.js" defer></script></head><body><divdata-tb-project-widgetdata-tb-project-slug="secured-finance"></div></body>
Paste the code where the label should appear. It uses no API credit and updates by itself.
Something 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