Quantstamp reviewed Hifi Finance on 21 Oct 2022: 31 findings, 0 critical; 7 marked fixed, 22 acknowledged, 2 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 | 0 |
| Medium | 1 | 9 | 5 |
| Low | 1 | 13 | 2 |
The `Fintroller` contract defines a debt `ceiling` for every `Bond` added. The following code within the `BalanceSheetV2.borrow()` implies that the total debt is the supply of the hToken corresponding to the bond: ``` uint256 newTotalSupply = bond.totalSupply() + borrowAmount; uint256 debtCeiling = fintroller.getDebtCeiling(bond); if (newTotalSupply > debtCeiling) { revert BalanceSheet__DebtCeilingOverflow(newTotalSupply, debtCeiling); } ``` However, it is possible to increase the supply of the hToken past the debt ceiling by calling the `HToken.depositUnderlying()` function. This function mints new hTokens so long as the equivalent amount of underlying is supplied. There are no checks to ensure that hTokens will not be minted past the debt ceiling.
The `BalanceSheetV2` includes functions for repaying debt (`repayBorrow()`, `repayBorrowBehalf()`) as well as liquidating debt (`liquidateBorrow()`). All these functions will revert if the user attempts to repay more than the vault's current debt. The code below causes a revert when a user attempts to repay more than the vault's debt. Similar logic applies to liquidations. ``` uint256 debtAmount = vaults[borrower].debtAmounts[bond]; if (debtAmount < repayAmount) { revert BalanceSheet__RepayBorrowInsufficientDebt(bond, repayAmount, debtAmount); } ``` The logic above can lead to a denial of service for liquidators or borrowers trying to repay their loans. If a user tries to repay the total amount of their debt, they may be front-run by a malicious liquidator, which calls `repayBorrowBehalf()` with a minimal repayment. The transaction for the normal user will revert because the amount they are attempting to repay exceeds the vault's debt by a small amount. A similar scenario can occur when a malicious user prevents liquidation by front-running a valid call to `liquidateBorrow()` with either a small repayment or a small liquidation.
Each collateral has its own liquidation incentive, and the owner can change this value using the `setLiquidationIncentive()` function. The issue here is that before running the liquidation, a change request can be made by the owner to front-run the liquidation transaction. The worst-case scenario is that the user will receive less than what was expected.
The `HifiProxyTarget` features many functions which either add or remove liquidity from the `HifiPool` contract. All of these functions are missing sufficient protection against slippage. Liquidity providers are subject to impermanent loss when the interest rate for the `HifiPool` changes. The interest rate depends on the size of the hToken and underlying reserves. Liquidity providers must provide the underlying tokens and hTokens in their desired expected ratio. They should also always receive the amounts of tokens they expect when removing liquidity if they do not wish to realize an impermanent loss. There are some functions within the `HifiProxyTarget` which provide some slippage protection such as `addLiquidity()` which requires a `maxHTokenRequired` parameter. This protects the price of the hToken going up. However, there is no protection provided against the price of the underlying token (relative to the hToken) going up. To sufficiently protect against slippage, a `minHTokenRequired` parameter should be used as well. Similarly, functions that remove liquidity, such as `removeLiquidity()`, lack parameters to indicate the minimum amount of hTokens and underlying tokens received. We have listed all functions on the `HifiProxyTarget` which lack sufficient slippage protection: * `addLiquidity()` * `addLiquidityWithSignature()` * `borrowHTokenAndAddLiquidity()` * `borrowHTokenAndAddLiquidityWithSignature()` * `buyHTokenAndAddLiquidity()` * `buyHTokenAndAddLiquidityWithSignature()` * `buyUnderlyingAndAddLiquidity()` * `buyUnderlyingAndAddLiquidityWithSignature()` * `depositCollateralAndBorrowHTokenAndAddLiquidity()` * `depositCollateralAndBorrowHTokenAndAddLiquidityWithSignature()` * `depositUnderlyingAndMintHTokenAndAddLiquidity()` * `depositUnderlyingAndMintHTokenAndAddLiquidityWithSignature()` * `removeLiquidity()` * `removeLiquidityAndRedeem()` * `removeLiquidityAndRedeemWithSignature()` * `removeLiquidityAndSellHToken()` * `removeLiquidityAndSellHTokenWithSignature()` * `removeLiquidityAndWithdrawUnderlying()` * `removeLiquidityAndWithdrawUnderlyingWithSignature()` * `removeLiquidityWithSignature()`
The `getBurnOutputs()` function calculated the number of `hTokensReturned` using the following formula ``` hTokenReturned = (poolTokensBurned * hTokenReserves) / supply; ``` If the `poolTokensBurned * hTokenReserves` is less than `supply`, `hTokenReturned` will be equal to `0` due to a loss of precision.
The owner has the ability to add a new bond token using the `listBond()` function. This will create the new bond with the default parameters. However if the owner injected an existing bond he will override the old bond.
As a best practice and to prevent unwanted external contract side effects, it is advised to adhere to the ["Checks-Effects-Interactions"-pattern](https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html), even in the presence of [reentrancy guards](https://docs.openzeppelin.com/contracts/4.x/api/[](https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html)security#ReentrancyGuard). Following instances were observed where said pattern was violated: - `HifiProxyTarget.addLiquidity()`: Performs external contract calls (`underlying.safeTransferFrom()`), before modifying state variables in following sub-calls. - `HifiProxyTarget.borrowHTokenAndAddLiquidity()`: Performs external contract calls (`underlying.safeTransferFrom()`), before modifying state variables in following sub-calls. - `HifiProxyTarget.buyHToken()`: Performs external contract calls (`underlying.safeTransferFrom()`), before modifying state variables in following sub-calls. - `HifiProxyTarget.buyHTokenAndAddLiquidity()`: Performs external contract calls (`underlying.safeTransferFrom()`), before modifying state variables in following sub-calls. - `HifiProxyTarget.buyHTokenAndRepayBorrow()`: Performs external contract calls (`underlying.safeTransferFrom()`), before modifying state variables in following sub-calls. - `HifiProxyTarget.depositCollateral()`: Performs external contract calls (`collateral.safeTransferFrom()`), before modifying state variables in following sub-calls. - `HifiProxyTarget.depositUnderlyingAndMintHTokenAndAddLiquidity()`: Performs external contract calls (`underlying.safeTransferFrom()`), before modifying state variables in following sub-calls. - `HifiProxyTarget.removeLiquidity()`: Performs external contract calls (`hifiPool.underlying().safeTransfer()`), before modifying state variables in following sub-calls. - `HifiProxyTarget.removeLiquidityAndRedeem()`: Performs external contract calls (`hToken.underlying().safeTransfer()`), before modifying state variables in following sub-calls. - `HifiProxyTarget.removeLiquidityAndSellHToken()`: Performs external contract calls (`hifiPool.underlying().safeTransfer()`), before modifying state variables in following sub-calls. - `HifiProxyTarget.sellUnderlyingAndRepayBorrow()`: Performs external contract calls (`underlying.transferFrom()`), before modifying state variables in following sub-calls. - `HifiProxyTarget.depositUnderlyingInternal()`: Performs external contract calls (`underlying.safeTransferFrom()`), before modifying state variables in following sub-calls. - `HifiPool.sellUnderlying()`: Performs external contract calls (`underlying.safeTransferFrom()`), before modifying state variables in following sub-calls.
An early liquidity provider can manipulate a single share's price to raise it high enough such that only the largest providers can afford to add liquidity. The share price can be manipulated by first only adding a tiny amount of liquidity and then directly transferring a large amount of the underlying token to the pool.
The contract approves an address to transfer tokens on its behalf without setting a limit of how many tokens may be transferred (`token.approve(spender, type(uint256).max);`). If the approved address becomes hacked or is intentionally malicious, it may transfer out all the approved tokens.
Function `ChainlinkOperator.setFeed()` saves new feed information in storage variable `mapping(string => Feed) internal feeds;`, which is indexed by the assets symbol string. Trying to add different assets with the same symbol string would lead to collisions in said array, overwriting the existing asset.
The clone-and-own approach involves copying and adjusting open source code at one's own discretion. From the development perspective, it is initially beneficial as it reduces the amount of effort. However, from the security perspective, it involves some risks as the code may not follow the best practices, may contain a security vulnerability, or may include intentionally or unintentionally modified upstream libraries. The `OwnableUpgradeable` contract is a fork of an OpenZeppelin contract. We identified a separate vulnerability in the report due to not using the latest OpenZeppelin contract. That vulnerability was the lack of inclusion of a storage gap which is included in the latest OpenZeppelin contracts.
Several instances of events are being emitted after external calls within the contracts of the protocol. If the functions were re-entered due to external calls, this could result in events being emitted out of order. We have provided a non-exhaustive list of such event emissions below: * `HifiPool` * `burn()`: Emits `RemoveLiquidity` after external calls. * `buyHToken()`: Emits `Trade` after external calls. * `buyUnderlying()`: Emits `Trade` after external calls. * `mint()`: Emits `AddLiquidity` after external calls. * `sellHToken()`: Emits `Trade` after external calls. * `sellUnderlying()`: Emits `Trade` after external calls. * `BalanceSheet` * `borrow()`: Emits `Borrow` after external calls. * `depositCollateral()`: Emits `DepositCollateral` after external calls. * `withdrawCollateral()`: Emits `WithdrawCollateral` after external calls. * `HToken` * `depositUnderlying()`: Emits `DepositUnderlying` after external calls. * `mint()`: Emits `Mint` after external calls. * `withdrawUnderlying()`: Emits `WithdrawUnderlying` after external calls.
The Hifi protocol allows for pausing numerous operations for collateral and debt assets through various functions within the `Fintroller` contract. However, many other critical operations are not pausable. While we realize that there is always a trade-off between centralization and stronger pause functionality, the Hifi team should consider adding the ability to pause the following: * Bond/debt operations * Withdraw underlying deposited within the `HToken` contract. This would prevent an attacker from withdrawing maliciously obtained hTokens. * Collateral operations * Withdrawal of collateral stored within the `BalanceSheetV2` contract. This could prevent an attacker from stealing or withdrawing excess collateral. * Liquidation of collateral assets stored within the `BalanceSheeetV2` contract. While the ability to pause operations on particular bond assets has been added, there is no way to disable the liquidation of a particular collateral asset. This may be desirable if the oracle temporarily delivers incorrect prices for a specific asset. * Prevent collateral from counting towards the user's total collateral value. If the team deems a collateral asset too dangerous to continue using, they may consider not allowing it to count towards a user's collateral value. Another mitigation would be raising the collateral ratio to the maximum value. * Trading operations * No pause functionality has been added for the AMM portion of the protocol. The team could consider adding functionality to pause adding/removing liquidity and trading functions.
Several functions within the protocol lack sufficient validation of their input parameters. We have listed all missing checks below: * `HifiPool` * `constructor()`: Validate that the `string` parameters are not empty. * `buyHToken()`: Validate that `_to` is not the zero address. * `buyUnderlying()`: Validate that `_to` is not the zero address. * `sellHToken()`: Validate that `_to` is not the zero address. * `sellUnderlying()`: Validate that `_to` is not the zero address. * `BalanceSheetV2` * `withdrawCollateral()`: Validate that `collateral` is listed within the `Fintroller`. * `HToken` * `constructor`: Validate that the `string parameters are not empty. * `ChainlinkOperator` * `setFeed()`: Validate that `asset` has `18` or fewer decimals. * `HifiProxyTarget` * `sellHToken()`: Validate that `minUnderlying` is greater than zero. * `sellHTokenWithSignature()`: Validate that `minUnderlying` is greater than zero. * `sellUnderlying()`: Validate that `minHTokenOut` is greater than zero. * `sellUnderlyingAndRepayBorrow()`: Validate that `minHTokenOut` is greater than zero. * `sellUnderlyingAndRepayBorrowWithSignature()`: Validate that `minHTokenOut` is greater than zero. * `sellUnderlyingWithSignature()`: Validate that `minHTokenOut` is greater than zero.
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.
Many contracts within the protocol feature a contract owner who has special privileges. We recognize that the Hifi team is in the early stages of progressive decentralization. However, all privileged permissions should be clearly documented for users. We have listed all the privileged permissions of the contract owners below: * `BalanceSheetV2` * The `fintroller` contract can be reset anytime, effectively resetting the whole protocol's configuration. * The `oracle` contract can be reset anytime, potentially changing the prices used for collateral and debt assets. * `Fintroller` * A new `Bond` can be listed at any time. * A new `Collateral` can be listed at any time. * The `Collateral` and `Bond` ceilings can be reset anytime, potentially increasing the protocol's risk exposure to a particular asset. * A collateral asset's collateral ratio and liquidation incentive can be reset anytime. * Collateral deposits can be enabled and disabled at any time. * Debt repayments, borrows, and liquidations can be disabled and enabled at any time. * The maximum number of bond assets per vault can be reset at any time. Setting this value too high could result in critical functions failing due to excessive gas usage. * `HToken` * The `balanceSheet` contract referenced can be reset at any time. * Transfer an arbitrary amount of any token from the contract, except the underlying token. * `ChainlinkOperator` * Price feeds can be added or removed at any time.
The protocol currently only allows adding tokens as collateral or underlying for HTokens with up to 18 decimals and not more. While in practice most tokens only use up to 18 decimals, this constraint could become a limitation in the future, especially given that this value is hardcoded and cannot be easily modified.
Numerous contracts within the contract either inherit from PRB contracts or use PRB libraries. These contracts are not within the scope of the audit and have not been previously audited [as stated by the author](https://github.com/paulrberg/prb-contracts#security). These contracts might have existing unfound vulnerabilities which could compromise the security of the Hifi protocol.
Several contracts within the protocol make external calls to functions with return values which indicate the result of those functions. However, many of these return values are not being validated within the code. We have listed all function calls for which return values are being ignored: All Calls to HifiPool.buyHToken() All Calls to HifiPool.buyUnderlying() All Calls to HifiPool.sellHToken() All Calls to HifiPool.sellUnderlying()
To prevent confusion and the introduction of vulnerabilities, test code should not be present in production code/should be clearly separated.
The various `initialize()` functions of upgradeable contracts are not constructors. There is a low-but-not-zero chance that someone can call these after the contracts have been deployed, but before the development team calls them. Consequently, contracts may get initialized with values that are not desirable by the development team.
The `getQuoteForBuyingHToken()` function is referenced by the `buyHToken()` function to determine how many underlying tokens are needed to buy a certain amount of hTokens. However, the resulting amount of underlying tokens is denormalized: `underlyingIn = denormalize(normalizedUnderlyingIn);`. This results in any amount of underlying token below `underlyingPrecisionScalar` essentially being discounted from the price. The result could be significant if the underlying token has much fewer decimals than `18` and the value of `1` full token is high. For example, in the case of WBTC, a user could receive up to `1` Satoshi worth of a discount. A similar issue exists in `getQuoteForSellingHToken()`, which results in a user paying more hTokens than they should for a certain amount of underlying tokens.
The report names a repository or files. Open the report to see its scope.
Repository and files the report names (33)
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