Notional V2 Technical Deep Dive Part 1: fCash Markets

Jeff Wu
Jeff Wu

In this series of posts, I will walk you through the Notional V2 smart contracts. The goal of these posts is to help technically minded users and developers better understand a large and complex codebase (~12,000+ lines of Solidity!). It’s expected that the reader has some familiarity with Solidity, Ethereum and smart contract programming in general. I will try to make these posts as reader friendly as possible but we will be getting technical here. If you are looking for a less technical overview of Notional V2, check out the documentation. Let’s get started!

fCash Markets define the interest rate for lending and borrowing on Notional V2. When users lend or borrow at fixed rates, they deposit or receive cash in exchange for fCash. fCash is a fixed amount of cash that a user is either entitled to receive (lending) or is obligated to pay (borrowing) at a specific point in time (maturity). For example, if a user lends 100 USDC (cash) in exchange for 110 fUSDC (fCash) maturing in 1 year, they will trade 100 USDC with the 1 year Notional V2 fUSDC Market and receive 110 fUSDC in their portfolio. The exchange rate between cash and fCash implies an interest rate depending on the time to maturity. With 1 year to maturity, the implied interest rate here is 10% annualized. Once a user's trade is executed, their interest rate is fixed. Notional V2 guarantees that their fCash position will be honored at maturity.

Cash to fCash exchange rates are determined by the Notional V2 liquidity curve, which is the focus of this post. First, let's take a look at the parameters that define an fCash Market.

  • maturity: fCash is only fungible within its own maturity and currency combination. Each fCash Market only trades fCash at a specified maturity.
  • totalfCash: Similar to a Uniswap pool, a Notional V2 fCash Market has two assets it exchanges between, cash and fCash. Note that totalfCash is a signed integer here. In many places we used int256 even when values can only be positive in order to limit the amount of conversion we have to do.
  • totalAssetCash: Notional V2 deposits cash (i.e. DAI, USDC, ETH, etc) in money market funds like Compound to ensure that users always earn a base level of interest. In the codebase we call wrapped versions of cash like cTokens "asset cash" which isn't the best name but we're stuck with it for the time being. Because asset cash is a different denomination than cash, there is a cash/asset cash exchange rate that can change over time.  50 cDAI may be convertible to 1 DAI at time t and later convertible to 1.1 DAI at time t + 1. This is an important fact we will revisit later.
  • totalLiquidity: Similarly to Uniswap, liquidity providers receive a liquidity token that represents their proportional share of both asset cash and fCash in a given fCash Market.
  • lastImpliedRate: When a user trades with an fCash Market they fix their interest rate but change the market interest rate for the next user. lastImpliedRate is this the interest rate that the last user traded at minus the liquidity fee. It is used as to determine the next user's interest rate.
  • oracleRate and previousTradeTime: these parameters will be discussed in a post on the Notional V2 valuation framework.
  • storageSlot and storageState: the Notional V2 codebase manages Ethereum storage slots directly using assembly.

Adding and Removing Liquidity

Lenders and borrowers don't trade against each other directly, they trade against liquidity providers. These liquidity providers ensure that there is always cash and fCash available for either lenders or borrowers at any point in time and receive trading fees in return. In addition to directly providing liquidity to fCash Markets, Notional V2 introduces the nToken which allows users to passively provide liquidity for an entire currency. nTokens are out of scope for this post but they are essentially baskets of liquidity tokens so this section is relevant.

When a user adds liquidity, they deposit asset cash (i.e. cTokens) and create an offsetting pair of fCash assets. For example, a liquidity provider who deposits 5000 cDAI may also add 100 fDAI to the market and receive -100 fDAI in their portfolio. In addition, the liquidity provider will recieve 5000 liquidity tokens to mark their claim on the market's assets. The ratio of cDAI to the size of the offsetting pair of fDAI assets will depend on the prevailing proportion of totalfCash to totalAssetCash. The liquidity provider now has a claim on their 5000 cDAI and 100 fDAI in the fCash Market. Critically, the -100 fDAI in the liquidity provider's portfolio and the 100 fDAI added to the fCash Market net out to zero. System wide fCash balances for any currency and maturity combination must always add up zero. This represents the fact that every lender will be paid what they owe by another borrower in the system.

When an liquidity provider removes liquidity, the receive their asset cash and fCash claims. If trading has occured, they may receive more or less cash or fCash depending on the trading that has occured. In all likelihood, the fCash they receive will not equal the negative fCash debt they incurred when providing liquidity. If aggregate trading has been on the lending side, the liquidity provider will be a net borrower (conversely they would be a net lender of aggregate trading was on the borrowing side). We call this net fCash balance an fCash residual, this will be important when we discuss nTokens.

The adding and removing liquidity code is not complex but it does result in some complex dynamics down the road:

Trading fCash

The Notional V2 website describes the product as lending and borrowing but fCash is better understood from a technical perspective as a future cash flow that can be bought and sold in fCash Markets. All cash flows have a negative (payer) and positive (receiver) side and fCash is similar.

Liquidity Curve Math

Before diving into the code, it's easier to first understand the math behind the liquidity curve. An in depth discussion can be found here so I will just leave the important formulas here.

\(fCash = cash \cdot exchangeRate\)

\(p = \frac{totalfCash}{totalfCash + totalCashUnderlying}\)

\(exchangeRate = \frac{1}{rateScalar}ln(\frac{p}{(1 - p)}) + rateAnchor\)

\(exchangeRate = e^(\frac{impliedRate \cdot timeToMaturity}{year})\)

Calculating a Trade

The entrypoint for trading fCash is the calculateTrade method in Market.sol. The fCashToAccount specifies the signed amount of fCash that a portfolio will take on. If it is negative, the user is borrowing. If positive, the user is lending.

getExchangeRate returns three important variables:

  • rateScalar which is a governance parameter that determines the amount of slippage during a trade.
  • totalCashUnderlying which is the totalAssetCash amount converted to underlying amounts given the current exchange rate. This is important because while fCash is denominated in underlying (i.e. DAI or USDC), cash balances are denominated in money market tokens (i.e. cDAI or cUSDC). We will discuss settlement in a separate post, but it's important to note that our exchange rates refer to a DAI to fDAI exchange rate (not cDAI to fDAI) so we have to do this conversion here.
  • rateAnchor is discussed in more depth in the link above. In short, because the interest rate a user receives is dependent on time to maturity, the exchange rate must also vary as time passes. The rate anchor is calculated to ensure that the current trade will be based on an exchange rate that gives the same implied rate as lastImpliedRate. rateAnchor can be either positive or negative depending on the proportion of totalfCash and totalCashUnderlying.

Using these factors, the next step is to calculate the exchange rate for this particular trade. This is done in _getExchangeRate:

Note that fCashToAccount is subtracted from totalfCash whether or not it is positive or negative.

The next step is to calculate how much cash an account will receive given this exchange rate. It's not a simple division because we need to also incorporate the liquidity fee and system reserve fee. These fees are denominated in annualized basis points so we scale the exchange rate based on the time to maturity in _getNetCashAmountsUnderlying. The difference between the pre fee exchange rate and post fee exchange rate is split between the liquidity providers and the protocol reserve fund. This method returns three variables:

  • netCashToAccount: the amount of cash (in underlying denomination) the account will receive. For lenders this is negative because they must deposit cash into the market.
  • netCashToMarket: the amount of cash (in underlying) the market will receive. This will be the opposite sign of netCashToAccount
  • netCashToReserve: is the amount of cash that goes to the reserve fund. This will always be positive.

In total, it must be that netCashToMarket + netCashToAccount + netCashToReserve = 0.

Next, we determine the lastImpliedRate which will determine where the rateAnchor will be set for the next user. Finally, we convert all the underlying balances to asset cash denominations and update the market state. Note that market states are only updated in memory in the _setNewMarketState, they are updated in storage via the setMarketStorage method. This is called during trading which will be discussed in a future post.

Wrapping Up

In this post we discussed how the Notional V2 fCash Market works. You may notice that the calculateTrade method only takes fCashToAccount as a parameter and does not calculate the inverse (i.e. specifying a cash amount to trade for fCash). Due to the shape of the liquidity curve, it's only possible to analytically calculate in one direction (either from fCash to cash or vice versa). We chose to calculate from fCash to cash so that we can exactly net off fCash balances in a portfolio when trading. If a user wanted to convert a 1000 fUSDC balance to cash, they would trade for -1000 fUSDC exactly and it would be removed from their portfolio with no "dust" amount.

We also include a function called getfCashGivenCashAmount which calculates this inverse using Newton's method to numerically approximate the shape of the curve. It's exposed as a view method for other developers to use but is not used directly in the protocol. This method is out of scope for this post but the math is described in the code comments.

TechnicalDeep Dive

Jeff Wu

Co-Founder and CTO