Quantstamp reviewed Altr on 13 Oct 2023: 18 findings, 0 critical; all marked fixed.
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 | 2 |
| Medium | 0 | 0 | 4 |
| Low | 0 | 0 | 12 |
The `getValuation()` of the `PriceIndex` contract is used to valuate a given NFT. The function returns a `Valuation` struct, which contains the fields `timestamp`, `price` and `ltv`. However, that struct is missing crucial sanity checks regarding its values: **1. Missing check that `Valuation.ltv <= 100`** The actually borrowed amount needs to satisfy the following require-statement: ``` require(_amount <= (valuation.price * valuation.ltv) / 100, "Lending: amount greater than max borrow"); ``` If `valuation.ltv` reaches values higher than `100`, the resulting expression would be bigger than `valuation.price`. This would allow borrow amounts higher than the actual collateral value, where an unknowing lender may accept the loan and in the worst case remain with the overpaid NFT. **2. Missing check that the price data is sufficiently fresh** The `timestamp` field is never checked to be within a reasonable freshness threshold, which could lead to stale valuation prices and therefore to potentially gross over-/undervaluations and either costing the borrower or the lender unnecessary funds. **3. The `acceptLoan()` function is not double-checking the oracle** Currently, when accepting a loan, the loan request's collateral is not double-checked on-chain by the lender. Given that a loan request with a sufficiently high deadline could be arbitrarily old, the oracle should again be consulted to protect lenders from possibly accepting underwater loans.
In `Lending.sol#L61`, `L124`, `L204` and `L249` the code documentation states certain fees and rates to be in `basis points`. Basis points are usually defined as `10000 = 100%`, `1000 = 10%`, `100 = 1%`, `10 = 0.1%` and `1 = 0.01%`. However, in `Lending.sol#L22` the `PRECISION` is defined as: ``` uint256 public constant PRECISION = 10000; ``` and different protocol fee maxima are defined as multiple of that value: ``` uint256 public constant MAX_PROTOCOL_FEE = 4 * PRECISION; uint256 public constant MAX_REPAY_GRACE_FEE = 4 * PRECISION; uint256 public constant MAX_BASE_ORIGINATION_FEE = 3 * PRECISION; uint256 public constant MAX_LIQUIDATION_FEE = 15 * PRECISION; uint256 public constant MAX_INTEREST_RATE = 20 * PRECISION; ``` In the following lines values are wrongly computed: 1. `Lending.sol#L411`: `platformFee += (totalPayable * repayGraceFee) / PRECISION;` 1. `Lending.sol#L675`: `(_borrowedAmount * liquidationFee) / PRECISION` So the fees are not interpreted as percentage shares, but multiplying factors of the borrowed amount, resulting in the borrower being required to be pay a multiple of the borrowed amount as a penalty when paying during the grace period or liquidators being required to pay back a multiple of the borrowed amount as liquidation fees.
ALT-1 highlighted that the current test suite is inadequately testing the outputs of functions from the smart contract. With closer inspection, rather than testing the functional correctness, most of the tests seem to have been built with the smart contract output in mind, as e.g. they simply compare the smart contract result with hardcoded values. The test `testGracePeriod()` in `Lending.t.sol` for example can be seen to consider a loan repayment within the grace period with over 300% interest rate as valid, as the `repayGraceFee` is set to `25000`, being interpreted as 250%. Overall, we deemed the test suite to be slightly minimalistic around some of the features of the protocol too, such as multiple possible loan durations and the application of the fee reduction factor.
While the protocol allows for multiple different borrow tokens to be used, as defined with `setTokens()`, the value returned by the `PriceIndex` valuation contract when requesting a new loan is indifferent to the target borrow token and its decimals, always returning the same nomination. Given an NFT valuation, the following check would be indifferent to the target borrow token decimals, potentially grossly mistreating an allowed borrow amount: ``` require(_amount <= (valuation.price * valuation.ltv) / 100, "Lending: amount greater than max borrow"); ``` For example, while the valuation could be designed for tokens with 6 decimals, requesting a loan for a token with more decimals would lead to unnecessary borrow restrictions as a potentially only much lower amount would pass the check. Similarly, a token with less than 6 decimals may allow for borrow amounts exceeding the actual collateral value.
Certain contracts have state variables, e.g. `owner`, which provide certain addresses with privileged roles. Such roles may pose a risk to end-users. The `owner` address has sole permission to call the following functions: * `setPriceIndex()` to update the pricing oracle. Malicious oracles could return incorrect values at the disadvantage of lenders. * `setGovernanceTreasury()` to update the address the protocol fees are transferred to. * `setRepayGracePeriod()` to update the period under which users can repay their expired loan before being liquidated. * `setRepayGraceFee()` to update the fee associated with such a delayed repayment. * `setProtocolFee()` to update the fee applied to the borrowed amount. * `setLiquidationFee()` to update the fee applied to the borrowed amount. * `setBaseOriginationFee()` to update the fee regarding the origination fees of the protocol. * `setTokens()` to add token addresses to be requestable for loans. * `unsetTokens()` to remove token addresses from that list * `setLoanTypes()` to update or add the APRs for certain loan durations. * `unsetLoanTypes()` to remove certain loan durations from being requestable for loans. * `setFeeReductionFactor()` to update the factor by which to reduce the origination fee given the loan exceeds a range. * `setRanges()` to update the origination fee ranges determining how often the `feeReductionFactor` is applied. While the core interest rate fetched via the aprFromDuration mapping updateable via the `setLoanTypes()` mapping is hard coded at the time the loan is requested, it should be highlighted that in following fee calculations of the loan, the other fees are always fetched from the latest state of the contract, possibly differing from the fees the borrower originally requested the loan for. So technically, it is possible that accepted loans at the time of repayment face a different set of `originationBaseFee`, `gracePeriodFee`, and `protocolFee`, though reasonable constant maximum values for those fees are enforced, enabling them only to collectively reach a value of 11 %. Also, loans that are within the grace period could become instantly liquidatable if the owner would e.g. suddenly greatly reduce the `gracePeriod`.
Currently, the oracle used to assess the collateral value of an NFT is only consulted once when the loan is originally requested. The protocol has been designed in a way that loans can not be liquidated once the collateral falls below a certain value; a loan can only ever be liquidated if the loan has not been repaid within the loan's expiry date with an added grace period. When the value of the NFT collateral falls sufficiently the loan becomes undercollateralized, which indicates that the borrower lacks sufficient collateral, thereby exposing the lender to the risk of potential loss. Additionally, in case of the loan becoming undercollateralized, the incentives for liquidators diminish too: Liquidators pay off the original loan and its accumulated interest rate and pay an additional liquidation fee to the protocol treasury, for which they get the NFT collateral in return. If the loan were to be undercollateralized at this point, liquidators would suffer a net loss, thereby would never invoke a liquidation. So the only possible course of action for lenders would be to claim the NFT that is now worth less than the due loan and interest. While this lender-side risk is present in other peer-to-peer NFT lending protocols, we noticed that it is not mentioned in the provided documentation.
In order to validate the proper deployment and initialization of the contracts, it is a good practice to emit events. Also, any important state transitions can be logged, which is beneficial for monitoring the contract, and also tracking eventual bugs or hacks. Below we present a non-exhaustive list of events that could be emitted to improve application management: * `setRanges()` should emit an `OriginationFeesRangesSet()` event * `setFeeReductionFactor()` should emit a `FeeReductionFactorSet()` event
The owner of the contracts can call `transferOwnership()` to transfer the ownership to a new address. If an uncontrollable address is accidentally provided as the new owner address then the contract will no longer have an active owner, and functions with the `onlyOwner` modifier can no longer be executed.
Currently, the contract does not make use of explicit rounding. Rounding should always be performed in a way that reduces potential attack vectors around abusing Solidity's implicit rounding. Therefore, the return value of the `getDebtWithPenalty()` function should be rounded upwards before being converted to a `uint256` again.
In the `getDebtWithPenalty()` function, only at the very end of the calculation the calculation of the `accruedDebt` variable, the value is converted to a `UD60x18`. In extreme edge cases this can lead to integer truncation where interest rate will be zero. Granted, we were only able to come up with zero interest rate cases for loans of unrealistically small amounts, yet we still think it is worth addressing.
As outlined in ALT-3, liquidators will have no incentives to liquidate undercollateralized loans. The other end of the scenario is worth considering too: an expired loan has not been repaid, even though the NFT has significantly increased in value. Both mechanisms around expired loans, the NFT claiming by the lender and the liquidation triggerable by anyone, are callable under the same conditions: `require(block.timestamp >= loan.startTime + loan.duration + repayGracePeriod, "Lending: too early");` If an NFT price would have increased at the end of a loan's duration, then lenders will want to claim the NFT. But liquidators can watch the mem-pool and front-run any lender trying to claim the now more valuable NFT. Liquidations are only ever profitable if it would also be profitable for the original lender to simply claim the NFT. The result is a lender left with a lesser profit than they should, as they "only" received the loan and the interest, while the front-runner received the more valuable NFT.
It is important to validate inputs, even if they only come from trusted addresses, to avoid human error. Specifically, in the following functions arguments, input validation is lacking: 1. `Lending.setTokens()` does not check whether parameter `_tokens[]` contains already set tokens (`allowedTokens[]`). Further, it does not check for duplicates. 1. `Lending.unsetTokens()` does not check whether parameter `_tokens[]` contains only set tokens (`allowedTokens[]`). Further, it does not check for duplicates. 1. `Lending._setLoanTypes()` does not check for duplicate entries in parameters `_durations[]` and `_interestRates[]`. Further, it does not check for zero interest rate values, which could be used to bypass `unsetLoanTypes()`. It should also check that `_durations.length == _interestRates.length`. 1. `Lending.setFeeReductionFactor()` does not check parameter `_factor` to be bigger than `PRECISION` and non-zero, allowing for increasing fees. 1. `Lending._setRanges()` does not check parameter `_originationFeeRanges` to: 1. Not contain duplicates. 1. Contain non-zero values. 1. Be strictly increasing. 1. Be limited in the number of ranges.
If the owner renounces their ownership, all ownable contracts will be left without an owner. Consequently, any function guarded by the `onlyOwner` modifier will no longer be able to be executed, which in this protocol would mean that all conditions for loans (APRs for certain durations and all the fees) would become constant.
Due to the potentially repeated multiplication with `PRECISION` and following division by `feeReductionFactor`, rounding errors could accumulate, as in each iteration of the `for` loop the 5 decimal percentual value is possibly rounded downwards. The calculation would benefit from a temporary increase in precision while potentially going through many divisions in the iterations.
In loan interest calculations, the contract employs a 360-day year measured in seconds as the foundation for annual percentage rates (APRs). However, this approximation isn't entirely precise, potentially resulting in APRs that are slightly higher than anticipated. For instance, if a user configures a loan for 365 days with a 10% APR, they might assume they'll pay exactly 10% in interest. Yet, in reality, the APR would be approximately 10.14% after the 365 days have elapsed. In our experience, most smart contracts adopt 365 or 365.25 days (to account for leap years) as the standard for APR calculations. While 360 days is sometimes used as a basis of APR calculations, it should be highlighted in documentation for end-users if this is the case.
An inherent danger when using NFTs as collateral in lending is that NFTs are subject to market manipulation, especially if the collection has low market liquidity. A malicious user could artificially inflate the price of a certain NFT by repeatedly bidding on their own NFT and selling it to themselves, thereby possibly greatly increasing the perceived collateral value for potential loans. Regarding the pricing of NFTs, the contract fully relies on the out-of-scope oracle. The client informed us that the oracle is expected to only contain prices for a selected group of NFT collections, where prices are maintained in the oracle through updates by the client via a 2-of-3-multisig. We want to raise awareness of the possibility of market manipulation around the process of the selection and price maintenance of the NFTs supported by the oracle.
The `repayLoan()` function currently determines the grace fee as a percentual share of the `totalPayable` variable. `totalPayable` is computed as the sum of the loan amount, the interest rate, and the protocol fee of the loan, the penalty applied to the debt, and the origination fee. While the penalty is zero if the loan is repaid in the grace period, with the current design, the grace fee is calculated based on the loan amount, the protocol's and the lender's interest rate, and the origination fee. While this matches the behavior stated in internal documentation that was provided to the auditors, we believe that the grace period fee should be solely based on the loan amount and not include any run-time costs.
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 report names a repository or files. Open the report to see its scope.
Repository and files the report names (2)
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