Ensure the security of your smart contracts

Modern DeFi Lending Protocols, how it's made: Curve LlamaLend

Author: Sergey Boogerwooger, Konstantin Nekrasov
Security researchers at MixBytes
The next lending in this series of articles: Curve LlamaLend. It's a very interesting lending protocol, built around the CrvUSD stablecoin and using its core component: an AMM. The features of CrvUSD include an intriguing math model and stabilization mechanics, which work with price bands (similar to ticks used in Uniswap V3), but in a different way. Additionally, CrvUSD employs soft liquidations, which function totally differently from traditional liquidations. The protocol is developed primarily in Vyper, along with many other notable features.

The Curve LLamaLend is a set of lending markets working with CrvUSD as stablecoin:

Let's take a closer look at it!
Higher-level design
The Curve lending uses the same mechanics and code as CrvUSD. This makes sense because most algorithmic stablecoins use debt/collateral positions very similar to lendings. The main difference is that in crvUSD new stablecoins are minted, while in lending, they are taken from the lending pool:

The "heart" of both mechanisms is LLAMMA, an AMM with a very interesting design. While we're discussing lendings, we cannot miss the LLAMMA mechanics as it adds unique properties to the whole protocol. For a better understanding of the stabilization mechanism of CrvUSD, which is currently used as a borrowable asset in Curve lending, it's very useful to read the CrvUSD whitepaper.

The liquidity in LLAMMA is organised in "bands", analogous to Uniswap V3 ticks (described here), where each band is responsible for its own price range. The operation of Curve's AMM within a single price band is similar to Uniswap V3. Swaps also "cross" bands sequentially, leaving the bands with only the borrowable asset to the left of the current band and bands with only the collateral to the right from the current band:

But in stablecoin and lending protocols, we always have an external oracle price of the collateral asset. The AMM in these protocols is used not only as a DEX, but also as a stabililzation mechanism capable of moving users' positions closer to a "healthy" status. The beautiful idea of CrvUSD (and lending) is to design an AMM that stimulates market makers to continuously exchange collateral<->borrowable tokens in a direction that leads to "healthier" users' positions.

Let's take a look at a graphic from the whitepaper:

We see the external oracle price p0, the "current band" price range (white part), and the price pAMM in our AMM. When a swap moves to the next band (exiting the white area), the AMM price increases/decreases more steeply than linearly (green and yellow parts). So, operations that shift the current band to the left and to the right "look ahead" to the market, setting buy/sell prices lower/higher than the market. This stimulates trading bots to make larger swaps and move faster to the market price.

This non-linear change of prices outside the current band is achieved by changing the entire(!) price grid for bands. This is a significant difference from Uniswap V3, where the intervals between ticks are fixed. In Curve, the prices "attached" to the bands above and below the current band change non-linearly, creating more incentives for market makers to swap in the "right" direction.

The collateral assets of users are placed in bands, and swaps in the LLAMMA simultaneously convert collateral to the borrowable token and vice versa. If this operation is made in the right direction, it buys "lowered" collateral increasing its price (when the collateral price goes down and users become more "unhealthy"), and sells the collateral in the other case.

This design allows Curve's AMM to "support" lending mechanics creating automatic liquidation incentives and "soft liquidations". So, the liquidations in CrvUSD (if prices are not going wild) aren't really liquidations. It's a continuous swapping of collateral-asset-to-debt-asset and vice versa, shifting users' positions towards the "healthy" status by trading bots. In case of abrupt price changes, Curve also has traditional "hard liquidations", similar to other protocols, allowing the repayment of user's debt for their collateral.

Soft liquidations make users' positions not constant because the user's collateral can be split into two parts: one in collateral asset (e.g., WETH), and the other in stablecoin (e.g., CrvUSD), with the ratio between these assets changing depending on the collateral price.

This design is very beautiful, but it has some interesting cases when prices change extremely and the difference between the AMM price and the oracle price is significant. In this case, the user's collateral can be fully converted to stablecoins. In this scenario, the "health" of the user's position becomes very strange, having, for example, CrvUSD as collateral and CrvUSD as debt, which adds some complications.

Now, let's see how the protocol is made.
The core of Curve LLamaLend minting/lending is the combination of three core contracts: Vault, AMM (LLAMMA), and Controller. These contracts work together to form a lending market for two tokens - one is borrowable, an the other one is used as collateral. There are two deployment variants for this combination: OneWayFactory and TwoWayFactory.

OneWayFactory creates a lending market for crvUSD + some collateral token. TwoWayFactory, on the other hand, creates two connected lending markets with "mirrored" roles for the tokens("long" market and "short" market). For example, in the first market, crvUSD is used as the borrowable token and WETH acts as collateral, while in the second market, the roles are reversed: WETH becomes the borrowable token and crvUSD is used as collateral.
The first step is to initialize the factory here, where we set the addresses for the AMM, controller and vault contract implementations, along with oracle addresses, monetary policy parameters, and other settings. Once initialized, the factory can deploy new lending markets (currently only with CrvUSD as stablecoin).

The factory includes several methods for creating the vault (user shares the holding contract) using an already existing AMM pool (like a regular Curve's swap pool) as a price oracle or using a custom price oracle. Each new lending market creation requires setting the A parameter (amplification coefficient), which acts as an analog to tickSpacing in Uniswap V3 ticks (described in the article is here).

Moving on to vault creation, this process involves creating both AMM and Controller here.

In case of TwoWayLendingFactory, we create two vaults. The key point here is that both lending markets use the same price oracle. The setup with two different price oracles makes TwoWayFactory particularly vulnerable to oracle price manipulation attacks, as even small price changes can lead to amplified arbitrage opportunities between the vaults.
The primary driver of lending, which is implemented in the AMM.vy contract. The most important aspect of LLAMMA relates to the connection between market prices and internal AMM prices, which is described here.

The most important function in LLAMM is _p_oracle_up(), calculating p_base * ((A - 1) / A) ** n - the upper price for the n-th band (the scary maths in code here is the exp implementation). This function controls the price ranges for each price band, and, when modified, "shifts" the distribution of price ranges across all bands. All other "up" and "down" prices (oracle and AMM) for each band are derived from this function.

The main working function in LLAMMA is _exchange() that, like in Uniswap, has two branches for calculating target swap parameters based on the required "in" or "out" amount of tokens. The main cycle "through" bands, updating each crossed band's reserves, is here. After this, the active_band of the AMM is changed.

Next in LLAMMA is the deposit_range() function, which takes collateral from the user and puts it into the range of bands (from n1 to n2). We split all the collateral into equal parts (y_per_band) and, for each band, add shares to the user and update the total shares of a band.

You might remember that liquidity providing in Uniswap V3 didn't use the operation "for each tick". Here we cannot avoid such "per-band" operations because of the "floating" grid of bands' prices and soft liquidations. We need an exact amount of shares for each user in each band, regardless of whether this band is fully in the collateral token or stablecoin. Otherwise, something similar to Uniswap V3 could be used to provide liquidity to the range of ticks.

The withdraw() function works similarly to deposit, iterating over the bands, removing liquidity and shares.
The Controller part of the Curve lending is presented in the Controller.vy, holding users debts in the loan mapping and holding the borrowable assets provided by lenders. When a user deposits() into the vault, the borrowed tokens are transferred to Controller. Controller, later transfers these funds to the user, creating a loan here.

A loan in Controller is created using the _create_loan() function, where we see the parameter N, controlling the number of bands where the collateral will be placed using equal parts. When debt is being prepared, we calculate the starting band n1 , from which the collateral will be placed in the next N bands. After storing the loan info, Controller calls the AMM.deposit_range() function, putting the collateral directly in the LLAMMA.
As in other protocols, Vault is an ERC-4626 contract (there's no reference implementation of ERC-4626 in Vyper) and mints vault shares to end users. All ERC-4626 functions are present; the two most important (_convert_to_assets and _convert_to_shares) are here. Now we can mention the usage of DEAD_SHARES to protect Vault from inflation attacks.

Vault could be integrated into Controller, but lending came out after CrvUSD, so it was added as an external module, responsible for lenders/borrowers shares.
Curve has a variety of oracles in its ecosystem. While it's possible to interface with Chainlink or other oracles, the most natural way is to use Curve's own pools, which already have significant liquidity.

For example, the WETH/CrvUSD lending AMM uses the CryptoFromPool.vy contract (at the address) that takes the price from the crvUSD-WETH-CRV pool.

An important part of the oracles design in CrvUSD is rate-limiting changes of the oracle price with the limit_p_o() function. Abrupt changes in the oracle price can lead to interesting effects on users' position health and AMM losses(will be described in details in our audit report).

Oracle price deltas also affect the dynamic fee for swaps (function get_dynamic_fee()), mitigating risks of significant losses in AMM in case of extreme price changes and sandwich attacks.
Risk management
First is the "health" of a user's position. The main function is health (internals here), which should be positive. The first part is calculated similarly to other protocols: it's the collateral/debt ratio. However, we also need to consider the price difference above the highest user's collateral band, which affects the collateral price (and the user's health). This part of logic is enabled by the full flag. This flag is "on" almost everywhere, except the repay() part, where a "pure" health ratio calculation is needed to avoid manipulations with price in AMM during liquidations.

Borrow and lend APYs (based on borrowing rates) are not constant in CrvUSD and are determined by monetary policy, presented in several variants (the freshest being SemilogMonetaryPolicy and SecondaryMonetaryPolicy). For example, the lending pool CRV/CrvUSD uses SemilogMonetaryPolicy.vy. This policy calculates the rate used in AMM and depends on the market utilization ratio (total_debt/total_collateral) Monetary policy can be changed by the factory admin.
Additional implementation details
Curve projects typically feature many interesting details in their implementations. Let's discuss some of them.

Vault and controller track underlying tokens balances directly. As we've seen in the Euler V2 article, some protocols choose to maintain their own internal copy of underlying token balances instead of simply using balanceOf(). Both approaches work: the "balance copy" is more resistant to "donation" attacks but is more expensive and can have potential problems with keeping two copies of the same balance. The direct balanceOf() approach is simpler and cheaper for users but may be more susceptible to "donation" attacks.

User ticks, holding information about shares in each tick, are stored as 128-bit values. Reading user ticks is implemented here, and user positions are stored as a mapping address => struct UserTicks, where userTicks holds the ticks range (as 2x128 = 256 variable) and an array with shares in each tick in the range.

The users_to_liquidate() function in Controller returns the list of users who can be "hard-liquidated". Such functions are not usually present in the protocols with liquidations and bot creators typically track positions to liquidate by themselves. This function helps them, but not signficantly, as liquidation bots mostly look at "future" liquidations, intercepting price changes and attempting to perform the liquidation via MEV first.

LLAMMA can also incentivize pools by attaching a special liquidity mining gauge contract with rewards for liquidity miners. This is done by registering self.liquidity_mining_callback here and calling it in many functions, for example in deposit_range() here. This feature comes from the first Curve pools and plays an important role for the projects, boosting the utilization of their tokens.

LLAMMA uses the precalculated sqrt and log values of expressions with parameter A, provided by the deployer, to avoid calculations of these expressions in contracts.
Curve LlamaLend is a unique combination of an advanced AMM and lending protocol, addressing real problems in DeFi like handling liquidations by users. LLAMMA, the core of the lending protocol, provides users with a safety net against hard liquidations, automatically converting their collateral to improve their position health in non-extreme market conditions. Existing Curve lending markets are almost entirely spared from "bad debt" problems, and even in volatile pools, the amount of hard liquidations is minimal.

The project is built around the CrvUSD stablecoin, but it can be used with any other stablecoin. We believe that markets with other stablecoins based on the LLAMMA stabilization mechanism could appear in the near future. All these features make Curve lending highly intriguing for any DeFi developer or auditor. Don't miss it. We will continue exploring significant lending protocols in our upcoming articles. Until then, stay tuned!
Who is MixBytes?
MixBytes is a team of expert blockchain auditors and security researchers specializing in providing comprehensive smart contract audits and technical advisory services for EVM-compatible and Substrate-based projects. Join us on Twitter to stay up-to-date with the latest industry trends and insights.
The information contained in this Website is for educational and informational purposes only and shall not be understood or construed as financial or investment advice.
Other posts