Ambit Finance engaged Guardian to review the security of its borrowing and lending platform. From the 20th of November to the 6th of December, a team of 6 auditors reviewed the source code in scope.
- Published
- Review window
- November 20 to December 6, 2023
- Language
- Solidity
- Chains
- BNB Chain
- Sector
- Lending
- 3 Critical
- 5 High
- 16 Medium
- 40 Low
- 0 Informational
Scope
Overview
Ambit Finance engaged Guardian to review the security of its borrowing and lending platform. From the 20th of November to the 6th of December, a team of 6 auditors reviewed the source code in scope.
Findings 64
-
SNAP-1 Critical Cardinality Errantly Incremented Logical Error Resolved
Description
In the
writefunction, if the cardinality is less than thesizethen the cardinality is always incremented, regardless of if a new index was written to or not.This perturbs the checking of the oldest snapshot on line 68 in the
findfunction as well as everything in thesearchfunction.Recommendation
Only increment the cardinality when a new index is written to in the
writefunction.Resolution
Ambit Team: The issue was resolved in commit ac233d2.
-
LOTY-1 Critical All Loyalty Rewards Can Be Stolen Logical Error Resolved
Description
Proof of concept: PoC
The functions
supply()andwithdraw()callaccrueRewards()before mutating the points balance of a user.burnTokens()does not which allows someone to claim an abnormally large portion of the rewards by abusing theirrewardsIndexand an artificially high balance of points that did not get accrued when incremented.This allows such a user to burn AMBT just before they withdraw and receive much more rewards than they should. This will also brick the reward claim process for other users as the reward token balance of the contract will not be enough to cover for their rewards.
Recommendation
Call
accrueRewards()before mutating the user'sbalance2inburnTokens().Resolution
Ambit Team: The issue was resolved in commit 277fba3.
-
LOTY-2 Critical Total Points Does Not Match Sum Of User Points Logical Error Resolved
Description
Proof of concept: PoC
When a user burns their Ambit, they are immediately credited with points that experience a 5x multiplier. However, if a user burns less than 1e7 of an Ambit, the
total.pointswill not increment:total.points += LoyaltyLib.boostMul(amount.toUint64(), LoyaltyLib.BURN_BOOST);This is because in the
boostMulfunction, the returned point value will be truncated to 0:points /POINT_DENOMINATOR * boost / DEFAULT_BOOST;A user could maliciously take advantage of this by initially burning more than 1e7 Ambit. This will be reflected in both the total and the user’s point balances as there will not be truncation.The user can then burn less than 1e7 Ambit which will not be reflected in the total of the Loyalty due to the truncation, but it will be reflected in the point balance of the user because the user’s
balance2is already above 1e7:points.balance2 / POINT_DENOMINATOR * BURN_BOOST / DEFAULT_BOOST;Because the total points are less than needed, the
tokenReward.indexwill be larger which will inflate the rewards to be dispersed:tokenReward.index += ((amount * REWARDS_MULTIPLIER) /loyalty.getTotalPoints()).toUint128();Users will not be able to claim their rewards as more reward tokens are attempted to be sent than actually exist in the contract. This can be repeatedly done by malicious users to widen the spread between the total points and sum of all users’ points.
Recommendation
In function
burnTokens, remove the user’s prior points from the total and add their new points with the boost similar to functionupdateBoostSimilarly adjust functions
withdrawandclaimPointsto avoid the discrepancy as well.Resolution
-
LDTR-1 High Small Positions Backed By The Vault Token Cause Liquidations To Revert Logical Error Resolved
Description
In the
liquidateVaultTokenfunction it is assumed that thetotalAmountis always available for withdrawal from the vault since in many cases it would have been previously repaid to the vault in theMarketLiquidation.settleLiquidationfunction.However in the case where all funds are actively being borrowed from the vault and a small account is liquidated while the
totalAmountis greater than the liabilities, there is not enough USDT to redeem from the vault and the liquidation will revert on line 397 in theDepositorVault.withdrawInternalfunction.Recommendation
Ensure the
totalAmountis capped at the liabilities earlier on in the liquidation logic, this way there is always guaranteed to be enough USDT to redeem from the vault in the event that the user’s collateral is the vault token.Additionally, do not allow withdrawals from the DepositorVault to go below the available balance returned by
getAvailableBalance. This way it is nontrivial to enter a scenario where there is no USDT left in the vault.Resolution
Ambit Team: The issue was resolved in commit a9d6785.
-
GLOBAL-1 High Snapshot System Prone To Instant Balance Change Protocol Manipulation Resolved
Description
The snapshot system was implemented in Ambit V2 to prevent the manipulation of the utilization rate through instantaneous balance changes in the Depositor Vault. However, the system only examines the latest snapshot, which can be trivially updated because the function
takeSnapshotispublic.A user can take a snapshot right after depositing in the vault, and now the snapshot will reflect the current balance. Consequently, a user can still instantly manipulate the utilization rate at will through their deposits and withdrawals and take a snapshot right after.
Recommendation
Modify the visibility of function of
takeSnapshottoprivate. Furthermore, take a snapshot upon borrowing and repaying as the total liabilities are modified.Consider taking a weighted average of the assets available across the past 4-10 snapshots so it is less prone to instantaneous manipulation. Also, consider utilizing a minimum
depositandborrowamount as well to limit a user from trivially creating many snapshots and pushing the 4-10 snapshots back.Resolution
Ambit Team: The issue was resolved in commit 8530b71.
-
LOTY-3 High Calling claimBoost Before Claiming Points Breaks The User's Boost Logical Error Resolved
Description
Proof of concept: PoC
The
UserPoints.boostis supposed to be at 10 (equating to 1x) but is 0 initially. WhenclaimPoints()gets called after waiting for 2 EPOCHs theUserPoints.boostis set in the following snippet:user.boost = user.boostOrDefault(); // @audit gets set from 0 to 10 hereThe user can make their boost larger by calling
claimBoost()if one is available to them. The current implementation of the system has aFirstLoanBoostModulewhich gives users an additional 0.1x on top of their 1x boost. The requirement for it is that they have borrowed more than 0 from the protocol and that they have a positive point balance throughUserPoints.total().The issue arises if the user burns AMBT with
burnTokens()before callingclaimPoints()to set their initialUserPoints.boostto 10 and then callsclaimBoost(). This would set their boost to 0.1x instead of to 1x due to the following LoC:user.boost = Math.min(user.boost + boost, LoyaltyLib.MAX_BOOST).toUint64();Here
UserPoints.boostis not yet set to the default boost, hence the user's boost for theirbalance1becomes 10x smaller than it should be and causes them to lose out on 90% of the rewards they should be receiving.Recommendation
Consider implementing the following changes across the
Loyalty.solfile:- Check whether the
UserPoints.balance1specifically is> 0for boost eligibility. - Set the
UserPoints.boosttoUserPoints.boostOrDefault()inburnTokens()andsupply().
Resolution
Ambit Team: The issue was resolved in commit 8c37f44.
- Check whether the
-
ML-1 High Health Score Decreases After Liquidation Logical Error Resolved
Description
Proof of concept: PoC
Because each token has a different LTV, a liquidation in a specific token produces a change in borrowing power that is not proportionate to the change in liabilities. As a result, it is easily possible for a position to have a lower health score (become unhealthier) post-liquidation.
The position would require more than 1 liquidation at 50% of the liabilities until the health score increased. Consequently, bad debt can stay in the system for a prolonged period and put the protocol at risk of supporting an insolvent position.
- 1000 USDT in liabilities
- 6 ETH each at $100 for 100% LTV ($600 borrowing power)
- 1 BTC each at $400 for 50% LTV ($200 borrowing power)
- Health Factor: ($600 + ($400 * 0.5)) / $1000 = 0.80
Because ETH is the largest position in the portfolio, the ETH will be liquidated.
- 50% of 1000 USDT (liabilities) = 500 USDT
- Proportionate supply = 500 USDT / $100 (ETH price) = 5 ETH
- 500 USDT in liabilities
- 1 ETH in the portfolio ($100 borrowing power)
- 1 BTC in the portfolio ($200 borrowing power)
- New Health Factor: ($100 + ($400 * 0.5)) / $500 = $300 / $500 = 0.60
Recommendation
Prior to liquidating a position, simulate the health factor post-liquidation. Carefully select the asset to liquidate which will ultimately increase the health factor. Furthermore, consider supporting full liability liquidations.
Although this may add complexity as multiple swaps in the portfolio may be needed due to the different supply tokens, it will ensure the borrow position is healthier post-liquidation.
Resolution
Ambit Team: The issue was resolved in commit 2b4c71d. 24
-
GLOBAL-2 High USDT Treated as One Dollar Logical Error Acknowledged
Description
During the liquidation of a position involving a different ERC-20 token, a swap occurs. In this swap, the
minAmountOutis calculated to ensure a minimum amount of tokens is received. However, theestimateSellAmountfunction calculates theminAmountOutbased on the notional value, by multiplying the amount (amount1) by the price (amount2):uint256 minAmountOut = amount1.mulDiv(amount2, 10 ** decimals);Changes in the asset's price can lead to
minAmountOutbeing either larger or smaller than the input amount. If the price rises, theminAmountOutmay exceed the input, causing the swap and liquidation attempts to fail. Conversely, if the price falls,minAmountOutmay be much lower than intended, enabling swaps to exceed the intended max slippage.Other areas where USDT is assumed to be $1 is the liabilities. Because the liabilities are used in calculations such as an account’s health factor, an account may appear unhealthy when it is not and vice versa.
Recommendation
Modify the calculation for
minAmountOutin theestimateSellAmountfunction to be based on the expected token amount rather than the notional value. This adjustment ensures more accurate and reliable swaps during liquidation. Furthermore, use a USDT price feed to retrieve the market price.Resolution
Ambit Team: Because we are a lending protocol that only lends out a single asset, the liabilties and borrow limit are denominated in that base asset, in this case USDT. Additionally, all price oracles are based on USD.
-
ML-2 Medium findLargestPosition Uses Ordinary Price Instead Of Discounted Logical Error Acknowledged
Description
findLargestPositionfinds the largest position in a user's portfolio and returns it to be liquidated. The issue arises due to it using the ordinary prices from the oracle instead of the ones with applied discounts when calculating position size.This will cause some positions, which are normally of higher value but have a higher discount to still be selected over ones with a lower value and a lower discount that equates to them being worth more when liquidating:
(, USD[] memory totals) = portfolio.getPortfolioValue(portfolioAssets);Recommendation
Consider using the discounted prices to find the position that will liquidate the most amount of assets.
Resolution
Ambit Team: We have discussed this in the past, however, using the position that has the largest USD value (ignoring the discount) is preferrable as it allows for the greater portion of liabilities to be repaid.
-
TRH-1 Medium Stored Custodian Becomes Invalid Upon Migration Migration Resolved
Description
The Custodian is kept as a separate, immutable variable inside of the TokenRewardHooks instead of
_asset.custodianbeing used. Upon Custodian migration through theCustodianMigrator, the address of the Custodian will change andTokenRewardHookswill continue to reference the old Custodian. This necessitates aTokenRewardHooksre-deploy as_custodiancannot be reset.Recommendation
Consider using the Custodian directly on the
_assetinstead of setting_custodian.Resolution
Ambit Team: The issue was resolved in commit 310287d.
-
TRH-2 Medium The Balance Of Any Disabled Token Can Get Hijacked Access Control Resolved
Description
Any asset, that gets disabled will have it's balance hijacked due to a lack of access control on
claim(). Functionclaim()callsaccrue(_tokenRewards[token]), which is a private function only also called inaccrue(). This function updates the current index of each epoch on the token based on thetotalSupplyof custodian shares at the moment.Due to the asset not being in the
_rewardTokensanymore, it does not get its index updated upon a user change.Consequentlyclaim()is prone to a Portfolio share inflation attack that steals the whole balance of the said reward token.This can be achieved by depositing a large amount of the asset, calling
claim(), which updates the accrued for that token based on the now inflated balance of shares and then transfers an inflated portion to the user.Recommendation
Put only active token access control on
claim().Resolution
Ambit Team: The issue was resolved in commit 115e128.
-
ML-3 Medium User Loses More Collateral Than Necessary Logical Error Acknowledged
Description
During a liquidation, the total amount to be liquidated and the amount of supply to withdraw from the user’s portfolio to the Liquidator is calculated in function
calculateLiquidation()With those parameters the liquidation is settled in function
settleLiquidation(), where the total amount being repaid is adjusted to be no more than the user’s liabilities:uint256 repayAmount = Math.min(context.totalAmount, context.liabilities);Consequently, the amount to repay can be dramatically decreased yet the amount withdrawn from the user's supply is still the originally calculated supply. Although the the excess of the user’s liabilities is refunded, the supply was valued at the discounted price so the user has more supply withdrawn from their portfolio than necessary.
This results in an extra “liquidation fee” causing loss of funds for users.
Recommendation
Consider adjusting
context.positionby the discounted price to accurately reflect the amount being repaid. Otherwise, clearly document this behavior to users.Resolution
Ambit Team: Yes this is correct, however, it will only occurr for accounts that have liabilities less than the smallAccountThreshold (which would probably be set to somewhere between 250-500).
-
ML-4 Medium findLargestPosition Exposes Liquidator to Unnecessary Slippage Logical Error Acknowledged
Description
In the
liquidatefunction, a liquidator is forced to liquidate from the asset with the largest notional value. This approach can result in less profit for the liquidator, especially when the position with the highest notional value involves a less liquid pool, this is because smaller pools experience more slippage during swaps.This limitation makes positions with certain assets as their largest holding less attractive to liquidate due to reduced profits, increasing the likelihood of incurring bad debt.
Recommendation
Allow liquidators to choose which asset they want to liquidate.
Resolution
Ambit Team: The MarketLiquidation contract does expose a second liquidate method that allows the liquidator to determine which asset they want to liquidate as opposed to the asset with the largest notional value. However, the initial implementation of the Liquidator is relying on the method that utilizes the findLargestPosition in order to favor paying off more debt in this scenario.
-
MKT-1 Medium User Can Avoid Accrued Interest Protocol Manipulation Resolved
Description
Proof of concept: PoC
The Ambit protocol supports the usage of a
DiscountModelto decrease the interest accrued on a user’s borrow position. Once theDiscountModelis set, the interest is calculated with the functioncalculateDiscountedLiabilitiesinstead of the typical interest calculation.With the model a user can receive a discount for their entire borrow amount:
Math.min(userLiability.borrowed, points * AMOUNT_PER_POINT).toUint128();A user may be in the market for a prolonged period of time while there is no discount campaign and the
marketState.borrowIndexwill largely increase during that elapsed time. In order to avoid paying that interest, a user can wait toaccrueLiabilitiesuntil a discount model is set.A user can burn Ambit to immediately increase their points such that the discount matches the borrow position, although this may require a large amount of Ambit. Afterwards the user will call function
accrueLiabilitieswhere the resultant interest will be zero, and the user will avoid the interest that they would have had to pay otherwise.Recommendation
Be extremely cautious with the discounts in the
DiscountModelto prevent users from creating interest-free borrow positions. Furthermore, accrue liabilities for the user prior to burning Ambit.Resolution
Ambit Team: The issue was resolved in commit 02bedd8.
-
MKT-2 Medium MarketState Not Updated Prior To Calling Pause/Unpause Logical Error Resolved
Description
When a market is paused, interest no longer accrues for positions in the
calculateMarketStatefunction as the function ceases execution early ifpaused()is true. However, when a market is paused with thepausefunction, the pending interest is not accrued.Therefore, when a market is paused, the interest accrued since the last
accrueLiabilitiescall is effectively lost, as subsequent calls toaccrueLiabilitiesupdate themarketState.lastUpdatetimestamp without accruing interest.Additionally, when a market is unpaused, the protocol does not ensure that
accrueLiabilitiesis called prior to unpausing the market. This would result in the interest accrued since the lastaccrueLiabilitiescall needing to be paid, since we no longer enter thepaused()is trueRecommendation
Call the
accrueLiabilitiesfunction prior to calling pause/unpause on the marketResolution
Ambit Team: The issue was resolved in commit 48aeb70.
-
LDTR-2 Medium Possible Lack Of Incentive For Liquidations Incentives Resolved
Description
The
treasuryFeeis taken at a higher priority than the caller fee for liquidations. In the event where there is only enough profit to fully pay out the treasury fee, the treasury fee is paid and thecallerFeeis reduced. Therefore the incentive for a liquidator to expeditiously liquidate an account may be reduced and possibly insufficient in some cases.Recommendation
Consider taking the
callerFeeas the highest priority so that there is always sufficient incentive for liquidations to occur in the system.Resolution
Ambit Team: The issue was resolved in commit ce65380.
-
MKT-3 Medium discountModel Updated Before Liabilities Accrue Logical Error Resolved
Description
In the
setInterestRateModelfunction, the pending interest is updated with theaccrueLiabilitiesfunction to correctly account for the outstanding interest having accrued under the previous interest rate model.However in the
setDiscountModelfunction the new_discountModelis set before the interest is updated with theaccrueLiabilitiesfunction, therefore any pending interest is treated as if it had accrued with the new discount model configured while this is not the case.Recommendation
Update the pending interest values with the
accrueLiabilitiesfunction before updating the_discountModelin thesetDiscountModelfunction.Resolution
Ambit Team: The issue was resolved in commit 1a1d62c.
-
TRH-3 Medium Token Rewards DoS DoS Resolved
Description
Since the token reward epochs are being pushed into the array on every donation and never removed, there is a risk of DoS because users who supply for the first time will need to go through the entire list of expired epochs for each of the reward tokens present.
This will cost users who are supplying to their portfolio for the first time to pay an excessive amount of gas overhead, which may potentially exceed the block gas limit and prevent the user from supplying.
Recommendation
Consider not going through all past epochs when accruing for a new user and just setting the epoch index to the current one.
Resolution
Ambit Team: The issue was resolved in commit 9b48119.
-
TRH-4 Medium Rewards During A Pause For A Reward Token Can Be Accrued Logical Error Resolved
Description
Paused tokens are not differentiated from when calling
accrue()on them. This allows a user to accrue them even though they should not be by callingclaim(). This then allows users to claim yield for a token that is currently on pause.Furthermore, tokens paused through
disableRewardToken()do not get accrued when callingaccrue(). This would effectively lose users yield.Recommendation
Consider having a check for if a token is paused or not in
accrue(TokenReward storagetokenReward)and returning early if it is.Also consider accruing paused tokens up until the pause point in order for all users to be able to claim them.
Resolution
Ambit Team: The issue was resolved in commit c4b376a.
-
TRH-5 Medium Invalid Result Returned From getClaimableRewards Logical Error Resolved
Description
Each reward epoch restarts the
accumulationIndexfrom 0. However in thegetClaimableRewardsfunction, theuserReward.accumulationIndexis never reset when iterating through the list of epochs. Therefore the resulting claimable reward amount received from thegetClaimableRewardsfunction will be inaccurate.Recommendation
Reset the
userReward.accumulationIndexto zero upon iterating to a new epoch.Resolution
Ambit Team: The issue was resolved in commit 746b070.
-
SNAP-2 Medium Untruncated Timestamps Are Unmatchable Logical Error Resolved
Description
In both the find and search functions, the method for validating whether a snapshot has been found or not is to check whether the supplied timestamp is exactly equal to the snapshot timestamp. However, this will rarely be the case as the provided timestamp is not truncated to satisfy the 5-minute intervals that the snapshot timestamps are stored with.
Recommendation
Truncate the provided timestamp so that it will be much more likely to line up with the snapshot timestamps.
Resolution
Ambit Team: The issue was resolved in commit 1b4c59a.
-
YBC-1 Medium Invalid totalAssets Validation Logical Error Resolved
Description
The
getTotalSupplyfunction returns thetotalAssetsbalance rather than the supply of the vault token.However in the
BaseCustodian.withdrawfunction, the shares amount is compared with the result of thegetTotalSupplyfunction. Therefore a shares amount is validated being against an underlying token amount.Recommendation
In the check in the
BaseCustodianon line 79 compare the amount which is a token amount against the result of thegetTotalSupplyfunction.Otherwise change the definition of the
getTotalSupplyfunction, but be careful to update where it is used, as the validation using thegetTotalSupplyfunction in thePortfoliocontract is currently correct as it assumes thegetTotalSupplyis an underlying token amount.Resolution
Ambit Team: The issue was resolved in commit 989c186.
-
MKTV-1 Medium Deleveraging Does Not Consider The Flash Fee Configuration Acknowledged
Description
The flash fee is not considered when approving the flash lender to pay back the flash loan, therefore the
MarketplaceVendorcannot be used to deleverage positions unless theMarketplaceVendoris a flash fee whitelisted address.The
MarketplacePurchaseron the other hand does account for the flash fee.Recommendation
Consider implementing flash fee support in the
MarketplaceVendor. If theMarketplaceVendorcontract is intended to always be a flash fee whitelisted address then be careful to always whitelist it.Resolution
Ambit Team: This is fine as the MarketplaceVendor is whitelisted and excluded from fees.
-
LDTR-3 Medium Liquidations Prevented By Slippage Protocol Manipulation Acknowledged
Description
Because some liquidations require a swap, they could be prevented if a malicious actor front-runs the transaction and moves the pool price out of the acceptable range. The malicious actor could sandwich the transaction to undo this price manipulation so that minimal capital is lost.
Consequently, the liquidations will be prevented by an insufficient output amount revert, leaving bad debt in the protocol.
Recommendation
Use an aggregator and/or ensure the discount is large enough to meet slippage requirements. Furthermore, consider allowing unprofitable liquidations to occur, marking the forceful liquidation with a boolean flag if necessary.
Resolution
Ambit Team: This should be fine as the Liquidator is the on-chain backup liquidation function. We have an off-chain liquidator that will be the primary source of liquidations and that uses 1inch to sell assets where applicable.
-
LOTY-4 Low User Points Not Getting Claimed In accruePoints Loses Them Rewards Logical Error Acknowledged
Description
Since accrued points are practically points that don't get rewards accrued on them users who call
accruePoints()but then do not directly claim in the same will lose out on rewards from those points.Recommendation
Consider calling
claimRewards()for users inaccruePoints().Resolution
Ambit Team: Users will only be subjected to claiming points which will internally accrue.
getClaimablePoints()method will return the up-to-date points without a call toaccruePoints(). -
MKT-4 Low Discounts Do Not Apply Without An Update To Liabilities Logical Error Acknowledged
Description
Liability interest discounts do not apply to a user's position if they do not explicitly update their liabilities in the discount period even if they have had that position since before the discount period.
For example, if the protocol goes from a standard interest rate to a discount rate and finally back to a normal rate, the user will not experience the discounted rate at all if the function
calculateUserLiabilityis not called during the discount period.Recommendation
Clearly document to users that not updating their liabilities during the discount period will not apply the said discounts.
Resolution
Ambit Team: The drawbacks are understood here, but unlikely to cause an issue as the primary purpose of the discount model is to allow points holders to have discounted rates.
-
PTFLO-1 Low User Can Withdraw And Supply In The Same Block Logical Error Acknowledged
Description
Users are not supposed to be able to complete a withdrawal and supply in the same block. It is prevented with the use of the
ensureWithdrawAvailability()function.The issue arises due to the
_lastUpdateBlocksmapping of a user for a particular token being deleted upon their whole balance being taken out. This allows a user to first withdraw and then supply the same token within the same block.Recommendation
Do not delete the
_lastUpdateBlocksmapping even if the user withdraws their whole balance.Resolution
Ambit Team: The check was only supposed to stop a supply followed by a withdraw in the same block, a withdraw followed by a supply should be fine.
-
ML-5 Low User's Discount Not Considered On Liquidation Logical Error Acknowledged
Description
When liquidating an account,
market.getLiabilities(account)is called to calculate the user's liabilities. However, the user can have their liabilities reduced if their accrued loyalty points were to be claimed and consequently have a healthy position.As a result, a user is liquidated when they could have a healthy position just by claiming their points, leading to loss of funds for the liquidated account.
Recommendation
Claim the account's points on the call to function
accrueLiabilities.Resolution
Ambit Team: We want to encourage the users to interact with the protocol and keeping the manual claiming of points is a way to do that.
-
MKT-5 Low Discounts Favor Borrowers With A Higher Liabilities/Principal Ratio Logical Error Resolved
Description
The discount percentage gets calculated as a fraction of the principle of the borrower's loan and then that percentage gets taken out of the total liabilities of the loan. The issue here is that this model heavily favors users who have a high liabilities/principal ratio.
This is against the interest of the protocol since those types of users likely haven't yet repaid large amounts of interest or have borrowed riskier assets.
Recommendation
Consider calculating the percentage with the total liabilities instead in order to favor users with lower accrued interest.
Resolution
Ambit Team: The issue was resolved in commit 1406fa3.
-
UNIPO-1 Low Uniswap Oracle Manipulation Oracle Manipulation Resolved
Description
One of the price oracles Ambit utilizes is the ratio of the reserves in a Uniswap pool. However, these reserves are highly susceptible to manipulation, as an attacker can manipulate the reserves of one of the tokens with a flashloan and exaggerate the price.
Due to the inflated worth of their collateral, an attacker's borrowing limit is increased and they are able to borrow more funds from the protocol than originally intended. This can drain the lending market and leave the protocol holding less value than stolen once the price of the collateral is restored.
Recommendation
Use the Uniswap TWAP for the price instead of the reserves calculation.
Resolution
Ambit Team: The issue was resolved in commit c58344b.
-
LDTR-4 Low MIN_SLIPPAGE_ALLOWED is never used Superfluous Code Resolved
Description
In the
liquidatorcontract the constantMIN_SLIPPAGE_ALLOWEDis set to 0.5%. However, the constant is never used and no minimum slippage is enforced.Recommendation
If the
MIN_SLIPPAGE_ALLOWEDis not intended to be used remove it.Resolution
Ambit Team: The issue was resolved in commit baa9a01.
-
PTFLO-2 Low Portfolio Hooks Prone To Reentrancy Reentrancy Acknowledged
Description
The Portfolio provides the capability to perform a particular action before supplying/withdrawing and after supplying/withdrawing through the use of hooks on a particular asset. Because state changes occur between the before hook and after hook, if a hook were to expose an external call outside of the protocol's system, a user could reenter.
One potential issue that could arise is if a user could use the before hook to re-enter into function supply and circumvent the maximum supply validation because the Custodian's supply has yet to be updated.
Recommendation
Be extremely careful with the hooks that are supported on each asset, so that calls outside of Ambit are restricted. Furthermore, consider adding
nonReentrantguards.Resolution
Ambit Team: This is accepted, however, hooks are still only an internal component used by the dev team when extending the protocol and this can be controlled.
-
DIRM-1 Low Current Market Liabilities Used For Utilization Logical Error Acknowledged
Description
The market’s utilization ratio is calculated using the instantaneous liabilities instead of using the liabilities stored in the snapshots:
uint256 liabilities = depositorVault.getLiabilities(marketAddress);Furthermore, the
Snapshot.totalLiabilitiesis not used at all. Ultimately this may lead to some gaming of the utilization ratio, and the liabilities can be directly affected with aborrowandrepay.Recommendation
Ensure the fees for borrowing are large enough to deter utilization ratio manipulation. Furthermore, consider using the average of the snapshot liabilities in the utilization calculation.
Resolution
Ambit Team: This has now been changed to use an average of the totalAssets. Additionally, the push the utilization higher, the user would have to borrow and in which case fees would be applied, and they would also have to have the assets supplied to their portfolio.
To push the utilization lower (and this decrease the rate) they would have to continually repay a loan and then borrow again and in which case the fees should provide an economic disincentive.
-
LDTR-5 Low Liquidations Halted Warning Acknowledged
Description
In times of volatility it may be possible for liquidations to drain the
Liquidatorcontract of the liquidation fund. In most cases the liquidator should receive back the USDT that was used to initiate the liquidation, however in some cases when liquidating vault tokens it may be possible for the amount of USDT in theLiquidatorcontract to decrease.Once the liquidator is drained no liquidations can occur and the protocol will accrue bad debt. Additionally, particularly large liquidations may eclipse the size of the current liquidation fund, in which case these liquidations would only partially occur and require several liquidations to finalize.
The risk is that the price of the collateral decreases further, increasing the chances of an insolvent position.
Recommendation
Be aware of this risk and ensure that the liquidation fund is always large enough to cover any liquidations.
Resolution
Ambit Team: The Liquidator is just the on-chain backup, there will be an off-chain liquidator running also that will handle this.
-
MKT-6 Low Users Are Charged Interest On Fee Amounts Unexpected Behavior Acknowledged
Description
When user's are charged fees, the fee amount is added to their principle and they simply do not receive that fee amount in USDT, instead it goes to the treasury.
Since the fee amount is still added to the user's principle however, the user still must pay interest on this fee amount that is taken. This may be unexpected for users and can cause confusion on the real amount of interest being charged.
Recommendation
Consider if this is the expected behavior, and be sure to document it for users.
Resolution
Ambit Team: Yes this is expected behavior.
-
LOTY-5 Low Superfluous Pending Rewards Calculation Superfluous Code Resolved
Description
The pending rewards for an account are added to the
userReward.accruedat the end of thegetClaimableRewardsfunction. However this is unnecessary as the pending rewards are already updated in theaccrueRewardsfunction call on line 258.Recommendation
Remove the pending rewards calculation from the return statement in the
getClaimableRewardsfunction.Resolution
Ambit Team: The issue was resolved in commit 1c1580d.
-
GLOBAL-3 Low Interest Fee Design Protocol Design Resolved
Description
Depositors only receive their rewards when borrowers choose to pay back their debt, however given that this is perpetual debt it is possible that depositors do not accrue their rightful interest until much later.
You lent funds in the vault in block 100, in block 101 User A borrows funds from the vault, in block 200 you withdraw funds from the vault, in block 210 User A finally repays their borrowed amount and pays out their interest to the vault.
However now you are not able to collect this interest given the time duration mismatch between the borrower leveraging the funds in the vault and actually paying back the accrued interest.
In other systems such as Abracadabra Money, the accrued interest is credited as it is generated and the system does not wait for users to repay their debt to provide yield.
Recommendation
Consider the drawbacks of rewarding depositors when a borrower repays their debt. In a future iteration it may be useful to consider a different method for distributing fees so that lenders are fairly compensated.
Resolution
Ambit Team: The issue was resolved in commit 1c1580d.
-
MKT-7 Low Typo Typo Resolved
Description
The word “accrued” is misspelled as “accurred” on line 258.
Recommendation
Correct it to “accrued”.
Resolution
Ambit Team: The issue was resolved in commit 929534e.
-
TRH-6 Low Donations Lost When totalShares Is 0 Lost Rewards Acknowledged
Description
When the
totalSharesis 0, theaccumulatefunction returns a 0 amount to be distributed, therefore donations will not be distributed when there is 0 shares for the_asset.Recommendation
The
TokenRewardHookscontract isSweepable, so these funds may be rescued by the treasury, but consider if this is the expected behavior. If not, consider implementing a method for donators to reclaim undistributed rewards. Or for an additional incentive for suppliers to acquire shares when the supply is 0.Resolution
Ambit Team: Yes this is fine, it’s unlikely that donations will be occuring without any assets being supplied.
-
ML-6 Low Blacklisted Accounts May Avoid Liquidation Blacklist Acknowledged
Description
Accounts that are blacklisted for USDT cannot be liquidated when the
refundAmountis nonzero as thesettleLiquidationfunction would attempt to transfer USDT to the blacklisted account. On BSC there is no blacklist functionality for USDT, however on other chains USDT does have a blacklist feature, so care should be taken when deploying to new chains.Recommendation
Carefully consider new chain deployments as certain features may be incompatible or exploitable on new chains.
Resolution
Ambit Team: When we do deploy to these chains we will look at the option to actually just keep the funds in the treasury if the account is blacklisted.
-
VEST-1 Low Typo Typo Resolved
Description
There is a typo in the variable
ellapsedas it should be namedelapsed.Recommendation
Rename the
ellapsedvariable toelapsed.Resolution
Ambit Team: The issue was resolved in commit 929534e.
-
DVLT-1 Low No Basis Point Validation For setBorrowLimit Validation Resolved
Description
In the
setBorrowLimitfunction, there is no safety check that the provided amount is within the max for a basis point value ifabsoluteOrRelativeisAbsoluteOrRelative.RELATIVE.Recommendation
Consider adding a safety check that the amount is within the expected range for basis point values if the
absoluteOrRelativevalue isAbsoluteOrRelative.RELATIVE.Resolution
Ambit Team: The issue was resolved in commit 382b0ad.
-
TRH-7 Low Redundant Accruals Optimization Resolved
Description
In the
afterSupplyandafterWithdrawfunctions theaccruemethod is invoked. However theaccruemethod will have already been called previously as a part of thebeforeSupplyandbeforeWithdrawfunctions.Recommendation
Remove the redundant calls to
accruein theafterSupplyandafterWithdrawhooks.Resolution
Ambit Team: The issue was resolved in commit 60a421b.
-
TRH-8 Low Superfluous tokenReward.index Update Superfluous Code Resolved
Description
In the
enableRewardsTokenfunction thetokenReward.indexis assigned to 0 if thetokenReward.indexis 0 and otherwise it is assigned to thetokenReward.index. This is pointless and therefore the line can be removed.Recommendation
Remove the superfluous
tokenReward.indexassignment on line 136.Resolution
Ambit Team: This was removed with the update to token rewards referenced in a few other issues.
-
YBC-2 Low Lacking Migration Logic Configuration Resolved
Description
There is no migration logic for the yield bearing custodian.
Recommendation
Consider if this is the expected behavior, implement migration logic if it should be migratable.
Resolution
Ambit Team: The issue was resolved in commit eeadec7.
-
YBC-3 Low Lack Of Donation Fee In The YieldBearingCustodian Protocol Fees Acknowledged
Description
There is no implementation for the
previewDonationFee, therefore there will be no donation fee or fee receiver.Recommendation
Be sure this is the desired behavior for the
YieldBearingCustodiandonations.Resolution
Ambit Team: Yes this is fine, only the DepositorVault has a donation fee.
-
DVM-1 Low Depositor Vault Key Re-computed Best Practices Resolved
Description
When the
depositorVaultis assigned in the registry the depositor vault key is recomputed by hashing the“ambit.depositorVault”string. However it would be a better practice to access and use the already computed keys from theAddressRegistryExtensionsfile. This way potential typos can be avoided when they would cause critical issues.Recommendation
Rather than manually hashing the key for the depositor vault, use the pre-computed
AMBIT_DEPOSITOR_VAULTkey from theAddressRegistryExtensionscontract.Resolution
Ambit Team: The issue was resolved in commit b8239f5.
-
DVM-2 Low Redundant Inheritance Superfluous Code Resolved
Description
The
DepositorVaultMigratorcontract inherits from both theAdminAccessControlandAuthorizedAccessControlcontracts.However the
AuthorizedAccessControlalso inherits from theAdminAccessControlcontract. Therefore it is unnecessary for theDepositorVaultMigratorcontract to directly inherit from theAdminAccessControlcontract.Recommendation
Remove the direct inheritance from the
AdminAccessControlin theDepositorVaultMigratorcontract.Resolution
Ambit Team: The issue was resolved in commit 77197c8.
-
YVLT-1 Low Invalid 0 Price Returned From getExchangeRate Unexpected Behavior Resolved
Description
The
getExchangeRateought to return an exchange rate of_scalarin the case where thetotalSharesis 0 as this will be the exchange rate for any deposit when thetotalSharesare 0.Recommendation
Consider updating the
getExchangeRateimplementation such that it returns_scalarwhen thetotalSharesis 0.Resolution
Ambit Team: The issue was resolved in commit 6d498c3.
-
SNAP-3 Low Unnecessary Truncate Call Optimization Resolved
Description
There is no need to truncate the
snapshot.timestampa second time as the timestamp has already been truncated on line 38.Recommendation
Remove the truncate function call on line 41.
Resolution
Ambit Team: The issue was resolved in commit 6d498c3.
-
ML-7 Low Users can be Liquidated for More than Needed Documentation Acknowledged
Description
Trusted liquidators have the capability to liquidate an arbitrary amount and from any asset in a user's portfolio. This flexibility allows a liquidator to intentionally liquidate specific assets and amounts in a strategic order to maximize their profit.
If a liquidator performs two liquidations— the first being as much as possible while keeping the position unhealthy, and the second being the full 50% of the user's largest position—they can liquidate more than if they had only done the 50% liquidation in the first attempt.
Recommendation
It is recommended to clearly document that there is no hard cap for how much a user can be liquidated.
Resolution
Ambit Team: Liquidation will be restricted to trusted actors so this shouldn't present a problem initially. If its decided to open this up then we will revisit.
-
SNAP-4 Low Unexpected Found Value Returned Unexpected Behavior Acknowledged
Description
In the find function on line 73, in the event that the queried timestamp is in the middle of the latest and oldest timestamps, the find function always returns true for the found result.
However a snapshot match might not have been found in the search function, and instead the first snapshot that is larger will be returned. This may not fit the consumer’s idea of the found boolean from the
findfunction.Recommendation
Consider if the search function should determine whether a snapshot was found or not depending on if the match case was hit.
Resolution
Ambit Team: The found parameter is supposed to represent that the requested timestamp was prior to any data that currently exists in the history and therefore the value could not be found, however, it returns the first available value in this case (which should be after the timestamp requested).
-
DMOD-1 Low Points Discount Extremely Low Logical Error Resolved
Description
- 1e8 AMBT = 10 PTS
- 1 PTS = 1e10 Wei Discount Amount (assuming 18 decimal precision of underlying vault token)
- 1e8 AMBT = 1e11 Wei Discount Amount
- USDT = 1e18 Wei
- 1e15 AMBT = 10,000,000 AMBT = 1e18 Wei Discount Amount
- To obtain 1 USDT discount a user may require 10,000,000 AMBT which is 10% of its supply.
Recommendation
Document to users this behavior and/or increase the discount.
Resolution
Ambit Team: This issue was resolved in commit 9fb99ed
-
MKTP-1 Low Late Validation Validation Resolved
Description
In the
buyfunction, the amount is first validated to be less than themaxAmountand the adjustedmaxAmountthat is subsequently used to flash loan is the minimum of the amount and themaxFlashLoanavailable in theFlashLender.However when the
maxFlashLoanis less than the amount the flash loan will always fail with the validation later on in theonFlashLoanfunction on line 130. Therefore this case is not validated early enough and needlessly allowed by theMath.minoperation on line 98.Recommendation
Validate that the amount is less than the
maxFlashLoandirectly in thebuyfunction to avoid unnecessary logic.Resolution
Ambit Team: This issue was resolved in commit 5e78aa3
-
DVLT-2 Low safeTransferFrom Should Occur Before Updates Reentrancy Resolved
Description
Calls to
safeTransferFromshould generally occur before any state updates in a function to avoid yielding an invalid state where updates have occurred before the tokens have been received.Recommendation
Move uses of
safeTransferFromto the beginning of therepayanddepositfunctions in theDepositorVault.Resolution
Ambit Team: This issue was resolved in commit 6652b39
-
SMMA-1 Low Invalid UniswapV2 Expiration Timestamp Logical Error Resolved
Description
block.timestampis used as an expiration timestamp for UniswapV2 swaps, practically the same as passing no timestamp. This presents an issue as the swap will go through no matter if the transaction stays in the mempool for a prolonged period.Recommendation
Consider using a timestamp passed from the caller.
Resolution
Ambit Team: This issue was resolved in commit 9b9ddda
-
DMOD-2 Low Precision Loss Can Lead to Loss of Loyalty Points Precision Resolved
Description
In the
calculateDiscountAmountfunction, if the vault asset’s decimals is less than 8 decimals the user can lose points due to precision loss when thenormalizefunction is called:uint256 points = loyalty.getPoints(account).normalize(8, decimals);Recommendation
To minimize the loss of points perform multiplication before division by normalizing
points *AMOUNT_PER_POINTinstead of justpoints.Resolution
Ambit Team: This issue was resolved in commit a414931
-
DMOD-3 Low Incorrect Integer Casting Logical Error Resolved
Description
The following
return (rate - DISCOUNT_RATE.percentOf(rate)).toUint128();is incorrect aspercentOf()returnsuint256. Instead of onlyDISCOUNT_RATE.percentOf(rate)being cast, the whole expression is cast.Recommendation
Consider re-implementing the expression like so:
return rate - (DISCOUNT_RATE.percentOf(rate).toUint128());Resolution
Ambit Team: This issue was resolved in commit 8df6db8
-
LDYV-1 Low Wrong Event Emission Logical Error Resolved
Description
emit Donate(msg.sender, amount - feeAmount, feeAmount, feeReceiver);The event above logs the donation amount as
amount - feeAmounteven though the fees also get logged by the event.Recommendation
Consider changing
amount - feeAmounttoamount.Resolution
Ambit Team: This issue was resolved in commit 5126b26
-
LDYV-2 Low Vault Distributes Yield Even With No Shares Present Logical Error Acknowledged
Description
LinearDistributedYieldVaultdistributes interest even if there are no shares/depositors in it. This will allow the first share depositor to get all the leftover yield.Recommendation
Consider not distributing yield if there are no shares in the vault.
Resolution
Ambit Team: This is fine as it should be rare that we yield without any shares as in theory if there's nothing in the vault then there is nothing to borrow to generate yield anway. Additionally if this did occur, it should encourage users to deposit.
-
DVLT-3 Low Vault Migration Sets The First Snapshot With The Newest Values Logical Error Acknowledged
Description
When migrating to a new
DepositorVaultinstance the first snapshot in the observer gets set as the latest params instead of copying the last few snapshots.Recommendation
Consider copying the last n snapshots into the new
DepositorVault.Resolution
Ambit Team: This is fine given;
- the frequency that we migrate the vault will be very low
- there's only a small window (10-15 mins) where the rate could be artificially forced low
-
ML-8 Low Very Small Positions In <8 Tokens Cannot Be Liquidated Logical Error Acknowledged
Description
All positions are denominated in USD with 8 decimals of precision. A very small loan in a token with less precision than the USD precision will get truncated to 0 when normalizing the decimals in
uint256 total = totals[j - 1].normalize(_registry.getDepositorVault().getDecimals());This will then offset all following calculations in
liquidateInternal()and will not allow for the position to be liquidated, leaving the protocol with bad debt it cannot remove.Recommendation
Consider introducing a minimum deposit amount.
Resolution
Ambit Team: This would be a 0 total as you’ve identified. However, the user wouldn't even be able to borrow against that position in the first place as the borrow limit is also normalized to the decimal precision of the base asset. It's a different story if the price of the asset was a lot higher and then crashed, but that's an inherent risk with an oracle based lending protocol.
-
MKT-8 Low Incorrect Order Of Operations Optimization Resolved
Description
uint256 elapsed = block.timestamp - marketState.lastUpdate;gets calculated before timestamp validity gets checked.Recommendation
Consider calculating
elapsedafter the check.Resolution
Ambit Team: This issue was resolved in commit f698173
-
GLOBAL-4 Low Disabling Hooks May Lead To Accounting Inaccuracies Logical Error Resolved
Description
Since
LoyaltyandTokenRewardHooksrely on hooks to accrue points and update indexes each time there is a change in the user's portfolio, freezing the hooks of a token will cause all logic in one of those two systems to experience accounting issues.Recommendation
Consider implementing contract freezing logic for the two systems to freeze all their logic in regards to the particular token when its hooks get removed.
Resolution
Ambit Team: This issue was resolved in commit cfd4e20
No findings match.
Invariants 39
The review's fuzzing suite asserted 39 invariants. 35 held and 4 did not.
Every invariant tested
| ID | Invariant | Result |
|---|---|---|
GENERAL-01 | Does not revert with underflow/overflow | Broken |
HS-01 | Health score is increasing after supply | Held |
HS-02 | Health score is decreasing after withdraw | Held |
HS-03 | Health score is decreasing after borrow | Held |
HS-04 | Health score is increasing after repay | Held |
HS-05 | Health score is increasing, or 0 after successful liquidation | Broken |
HS-06 | Health score is decreasing after borrow | Held |
HS-07 | Health score is above UNHEALTHY_HEALTH_SCORE_THRESHOLD after borrow | Held |
HS-08 | Health score is below UNHEALTHY_HEALTH_SCORE_THRESHOLD before successful liquidation | Held |
HS-09 | Liquidation always succeeds when health score is below UNHEALTHY_HEALTH_SCORE_THRESHOLD before liquidation | Broken |
PORT-01 | User can not withdraw more of one token than supplied | Held |
LIAB-01 | Liabilities are decreasing after liquidation | Held |
LIAB-02 | Liabilities of a user is increasing the exact amount that was borrowed | Held |
LIAB-03 | Liabilities of a user is decreasing after getting liquidated | Held |
LIAB-04 | Liquidation always fails if a user's liabilities is 0 | Held |
LOYAL-01 | Total loyalty points is equal to the sum of all loyalty points of all users | Broken |
LOYAL-02 | The exact burned amount is added to the user balance2 after burn | Held |
LOYAL-03 | Claiming points sets getClaimablePoints to 0 | Held |
LOYAL-04 | Loyalty boost can only be claimed once | Held |
LOYAL-05 | Loyalty points for a user should decrease or be equal after withdrawing Ambit | Held |
LOYAL-06 | Sum of pending, vesting, accrued loyalty points for a user should increase after supplying Ambit | Held |
REWARD-01 | Rewards claimed from claim must be equal to the points predicted by getClaimableRewards | Held |
VLTREV-01 | previewRedeem() does not revert for reasonable values | Held |
VLTREV-02 | previewWithdraw() does not revert for reasonable values | Held |
VLTREV-03 | previewDeposit() does not revert for reasonable values | Held |
VLTREV-04 | getTotalAssets() never reverts | Held |
VLTACCG-01 | Redeem must deduct shares from user | Held |
VLTACCG-02 | Redeem must credit underlying asset to user | Held |
VLTACCG-03 | Redeem must credit greater than or equal to the number of underlying asset predicted by previewRedeem | Held |
VLTACCG-04 | Withdraw must deduct shares from user | Held |
VLTACCG-05 | Withdraw must credit underlying asset to the user | Held |
VLTACCG-06 | Withdraw must deduct less than or equal to the number of shares predicted by previewWithdraw | Held |
VLTACCG-07 | Deposit must deduct underlying asset from user | Held |
VLTACCG-08 | Deposit must credit shares to user | Held |
VLTACCG-09 | Deposit must mint greater than or equal to the number of shares predicted by previewDeposit | Held |
VLTFREE-01 | Underlying asset is never seen as withdrawn for free using previewRedeem | Held |
VLTFREE-02 | Underlying asset is never seen as withdrawn for free using previewWithdraw | Held |
VLTFREE-03 | Shares are never seen as minted for free using previewDeposit | Held |
VLTFREE-04 | Underlying asset is never withdrawn for free using withdraw | Held |
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
