Ensure the security of your smart contracts

Modern DeFi Lending Protocols, how it's made: Ajna

Author: Sergey Boogerwooger, Dmitry Zakharov
Security researchers at MixBytes
Intro
"The Ajna Protocol is a noncustodial, peer-to-pool, permissionless lending, borrowing and trading system that requires no governance or external price feeds to function". No governance, no external price feeds - very intriguing!

Many modern DeFi protocols are moving towards more permissionless and trustless designs. While decentralization is often discussed, there are practical motivations behind this trend: a significant portion of attacks on DeFi protocols stem from oracle prices manipulations, errors in configuration and access control issues. Mitigating these risks 'by design' is highly attractive.

The Ajna protocol is a prime example of this approach; let's take a look at it!
Higher-level design
Our first stop is, of course, the whitepaper. The key idea of Ajna is the permissionless creation of pools, containing pairs of tokens (for supply and borrowing), similar to Uniswap pools. Each pool is divided into "buckets" - analogous to price ticks in Uniswap V3 (article) - where each tick represents a fixed ratio between the quote and collateral token (the amount of quote token per unit of collateral). This design allows lenders to choose a bucket and provide liquidity with their preferred LTV (Loan-To-Value) ratio. In Ajna, lenders must maintain their deposits close to market price (placing/moving them to the buckets "closer" to the market price) because an excessively high collateral price will result in losing the deposit (quote tokens) in exchange for a smaller amount of collateral tokens (and vice versa for underpriced buckets). Let's refer to the picture from the whitepaper:

Ajna uses the term "utilization" for a bucket. An utilized bucket is one where the sum of all deposits in "upper" buckets (priced higher than the current bucket) is less than the total debt of all borrowers in the pool. The lowest price among utilized buckets, or the "Lowest Utilized Price" (LUP), is a critical metric. Put differently, all deposits above the LUP are effectively being lent.

Another important threshold is the "Highest Threshold Price" (HTP), which is the threshold price of the least collateralized loan. It can also be also explained in other words as "all deposits priced above the HTP are offering liquidity to every loan in the pool".

The range of buckets between the LUP and HTP plays a significant role in Ajna. The movement of the LUP is influenced by the addition or removal of deposits and debts in different buckets. The movement of HTP is affected by the liquidations of the least collaterized loans. So, both the LUP and HTP are used to determine if the borrower's position is eligible for liquidation and whether a lender can withdraw their deposit.
Liquidations in Ajna remove the debt and collateral from the buckets and, in doing so, move the LUP and HTC values, shifting the market towards the actual market price. Liquidations remove debt from "bad" buckets, leaving only the buckets with the collateral price close to the market value.

The resulting liquidation price is determined using a Dutch auction, where the winner is the first actor to accept the continuously lowering price. Deposits that are currently "under liquidation" are frozen and cannot be withdrawn. In addition, depositing quote tokens at a price higher than the lowest price in the current liquidation auction is prohibited.

The whitepaper explains it better, but to simplify: all these restrictions, LUPs, and HTCs are designed to create a "sequential" liquidity movement between buckets according to the external market price. The Ajna protocol shifts the HTC-LUP range of buckets, "following" the market price of the collateral by "gathering" extra debt and collateral from the buckets above and below the "stable" range between the HTP and LUP.

Interest rates in Ajna are "market-derived" and not predefined or governed values. They are determined dynamically based on pool's utilization, with applied rate-limiting. Now, let's proceed to the implementation.
Core
Pool deployment
As usual, our first short stop will be on the pools deployment factories. Ajna has different types of pools: one deployed by ERC20PoolFactory.sol and the other by ERC721PoolFactory.sol. The former is for ERC20 tokens, the latter is for NFTs used as collateral. Since Ajna doesn't use external oracles and the lenders' positions are stored as NFTs, working with NFTs in buckets (instead of regular ERC20 tokens) is much simpler than in other protocols. There is no need to estimate the price of each NFT within the protocol; this job is performed by protocol traders, while the bucket logic remains the same. Pools are not upgradeable, but the mapping that keeps pool addresses for token pairs is built using a constant seed (here, or here), that can be changed to add new types and versions of pools.

The deployment of the pool in Ajna is straightforward and fully permissionless, like in Uniswap. Here you can see that creating a pool is really simple, and the only "governance-like" structures are mappings and lists with the addresses of used tokens to avoid duplicate pools with the same tokens (also similar to Uniswap).
Buckets and Fenwick trees
As discussed above, Ajna needs to operate on multiple buckets with separate debts/collateral values. Performing operations over large ranges isn't feasible in EVM and smart contracts without specialized data structures. According to the whitepaper, Ajna needs at least to:
  • evaluate the total deposit over a range of buckets (e.g., to calculate how much deposits are above the HTP)
  • search the highest price that has at least a given amount of deposit above it (e.g., for the LUP calculation)
  • increment/decrement deposit quantity in a bucket (e.g., for lending or withdrawing deposit)
  • multiply all deposit in a range by a given scalar (e.g., for accruing interest to lenders)
There is a special data structure for the effective calculation of a sum over the range of indices: a Fenwick tree (aka Binary Indexed Tree wiki, well-explained in the article). This tree allows calculating the partial sums of any (i, j] elements range in an array using O(logN) operations. These trees are used to effectively track "running total" values, where we need to continuously calculate the sum of the different ranges of the elements in an array that changes over the time.

Ajna uses two Fenwick trees:
It means that all operations in Ajna that change these partial sums or interest factor update not only the corresponding buckets info, but the whole tree as well. This might seem inefficient, but let's recall that the number of operations remains constant (log2N), where N is the number of buckets (7388 buckets). This results in ~13 iterations in each tree.

Examples of the usage of a Fenwick tree can be found in the addition of a deposit, in the LUP calculation within the findIndexAndSumOfSum() function and in some other places.
Lending and borrowing
The implementation of lending/borrowing operations in Ajna uses two main entry points: ERC20Pool.sol and ERC712Pool.sol, containing drawDebt() functions for borrowing (ERC20 and ERC721 implementations) and addCollateral() functions (ERC20 and ERC721 variants). These functions handle only basic checks and token transfers; the main logic of Ajna resides in LenderActions.sol and BorrowerActions.sol.

The core lending function, which adds quote tokens to the protocol, is the addQuoteToken() function from LenderActions.sol. This function works with a given bucket index and calculates the required amount of LP tokens for a specified amount of quote tokens (with the deposit fee already included). Then, global deposits are updated (note that this is not a simple accumulator value, but an update of the Fenwick tree here). Then, after saving lenders' LP (here) goes the calculation of the new LUP (here), which is used to update the pool state in updateInterestState() function.

This function, as described in the whitepaper (section 8 "Interest rates"), calculates EMA ("Exponential Moving Average") values for debts and deposits. While the details of Ajna's mathematical model are not the focus of this article, which is centered on "how it's made", it's important to highlight the key components of Ajna's lending/borrowing mechanics: the use of Fenwick trees for storing "per-bucket" values and the calculation of EMA values. These components play a crucial role in maintaining efficient and accurate tracking of financial metrics within the protocol.

The core borrowing function is located in Borroweractions.sol and is called drawDebt(). After performing amount checks and verifying that the debt is not currently being auctioned, the function prepares a DrawDebtResult struct. This struct contains "pre-" and "post-" operation values for collateral and debt (both for this particular position and the entire pool), as well as the new LUP ("Loan Utilization Price").

The new LUP value, along with the overall pool state, is later used for a "slippage" check (set by user's limitIndex_ parameter). This ensures that the new LUP hasn't dropped below a specified limit. Additionally, it is used in the main _isCollateralized() check (implemented here) to verify that the position remains correctly collateralized after the operation.

At the end of the borrowing process, the loan for a given borrower is updated. Here, Ajna employs another interesting solution: a "max heap" data structure. This kind of binary tree maintains a pre-sorted array of values, with the maximum value at the top of the tree.

The loans are sorted within this structure, and each new insert/update of the loan reorders the max heap tree. This data structure requires reordering when adding/updating/removing (example) the values, but it allows for the retrieval of the deposit with the highest TP (Threshold Price) by simply accessing the root index, making the operations involving the highest priority loans efficient.
Oracles
This is perhaps the most beautiful part of the article. There are no external oracles in the Ajna protocol :)
Risk management
Let's recall that each bucket in Ajna has a fixed ratio between debt and collateral. A fundamental principle of Ajna's approach is that it doesn't matter which tokens form a user's position; the fixed ratio makes collateral or debt amounts "equal" in a bucket. Operations in a bucket can include adding quote or collateral tokens (in exchange for LPB tokens) or removing quote or collateral tokens (by burning LPB tokens). In simple words, Ajna tells traders: "Here is the pack of differently priced buckets with debt/collateral tokens. You can trade in any of these buckets, but don't move the lowest utilized price (LUP) too low, avoiding the creation of undercollateralized loans".

Of course, this adds challenges with rebalancing the entire system according to market conditions, but it makes the core of the protocol very simple and stable. This design can be used to perform many DeFi operations, like limit orders or shorting. In addition, the absence of oracles makes using NFTs within the protocol much easier and safer.

To enable these debt->collateral and collateral->debt trades, LPB tokens are immediately redeemable upon minting. We can deposit quote or collateral tokens into a bucket, receive LPB tokens, and then withdraw collateral or quote tokens by returning just the LPB tokens received - all in a single transaction. Withdrawals of quote tokens reduce the overall deposit amount and lower the LUP, so these operations are restricted as borrowing operations (here), while adding quote tokens does not require restrictions (since it increases the LUP).

The design with "fixed price buckets" requires "move" operations, allowing lenders to move their deposits between buckets, it's implemented in the moveQuoteToken() function.

Liquidations in Ajna are also connected to the LUP. The base condition is:

which for a particular loan is rewritten as:

So, to check if a loan is eligible to liquidation, we simply need to compare its TP ("Threshold Price") < LUP.

Liquidations in Ajna are organized as a Dutch auction. There is a price that continuously lowers over time, and the first participant who agrees to the price performs a "pay-as-bid", completing the auction in one bid. Kickers can use deposits from other buckets to pay for this operation. Therefore, the liquidations in Ajna are not "classic liquidations" but rather a movement of debt/collateral tokens between the buckets, leading to a distribution that reflects the current state of the market.

There are two "kicking" functions: kick() and lenderKick. The first function directly liquidates a given borrower, while the second automatically selects the largest borrower to kick (depositors in Ajna can kick loans that would be undercollateralized if their deposit were removed). Both functions use the main _kick() function, which first calculates the NP ("Neutral Price"). As described in the whitepaper, the NP is a sort of a "middle point" in the liquidation's Dutch auction. Liquidating too early, at a price above the NP leads to a forefeit of a part of kicker's tokens. Over time, as price drops below the NP, the liquidation becomes more and more profitable. Information about the current liquidation is saved in the linked list containing borrowers' addresses. The auctions from this list are then sequentially used for settling the liquidations.

Another important mechanic in Ajna is the ability to take flashloans. Posting the liquidation bond by the lender requires some extra amount of quote token. With a flashloan, lenders can use their deposit to post the liquidation bond by obtaining a flashloan and then repay the flashloan with the withdrawn deposit. To make it possible, both ERC20Pool and ERC721Pool are inherited from the FlashloanablePool and allow them to call the flashLoan() function (Ajna follows the ERC-3156 spec). Flashloans in Ajna are available only with the quote token of the pool, so Ajna restricts the "flashloanable" token for a given pool.
Implementation details
Ajna has a well-structured and clear codebase, with all logical layers separated and functions that are concise and easy to understand. Token operations are handled exclusively at the "Pool" level, while all internal mechanics work only with representations of token balances.

Ajna employs special data structures to manage operations, such as Fenwick trees (for deposits and interest accumulation), linked lists (for liquidations) or "max-heap" trees (for loans). These structures are essential when dealing with multiple borrowers and buckets and need "top values" or "staked" operations ordering. You should keep these structures in mind when designing your own DeFi protocol.

Ajna uses a special RevertsHelper.sol library, containing important checks, that trigger reverts within the protocol. An example of such a complex check is _revertIfAuctionClearable(), which verifies both the expiration time of the auction and the remaining debt/collateral.

Maths in the Ajna protocol relies on the PRBMath library (Math.sol) operating with 59.18-decimal fixed-point values. Non-standard computations are primarily used for calculating dynamic interest rates (example: function updateInterestState()) based on EMA ("Exponential Moving Average") values of the LUP and debt/collateral amounts. As mentioned above, there is no governance management of interest rates; everything is made based on the pool's utilization and activity.
Conclusion
Ajna unquestionably stands as a noteworthy lending protocol due to its permissionless and decentralized nature. No external oracles, no governance - these features are very attractive, especially when working in new chains lacking stable oracle infrastructure and robust governance mechanisms.

Moreover, the absence of oracles allows Ajna to handle ERC721 assets as collateral using nearly identical mechanics as for regular ERC20 tokens. Estimating the price of different NFTs is a really tricky task, while oracle price manipulations remain among the most popular attack vectors on lending protocols.

Ajna's bucket mechanics, which divide the debt/collateral ratio into ranges and allow assets to be transferred between risk areas, enable Ajna's pools to track external market prices without relying on external price manipulations. But, unlike the previously discussed CrvUSD lending and Fluid's Vault protocol, Ajna requires interaction with each borrower's position to perform liquidations, like traditional lending platforms (Compound, Aave). These interactions are optimized using specialized data structures such as Fenwick trees.

DeFi protocols are beautiful thanks to their algorithmic nature and transparent mathematical models, striving to achieve stability and efficiency in the open market. Ajna represents another important step towards "true" decentralized finances. Let's keep it going!
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.
Disclaimer
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