Notional V2 Technical Deep Dive Part 2: Trading Actions

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!). In part 1, we described the Notional V2 fCash Market. This is the second post of the series where we will walk through what happens when a user lends or borrows by interacting with one of those fCash markets.

A typical user interaction on Notional V2 will likely involve multiple steps: depositing a currency (i.e. DAI, cDAI, ETH, etc), trading for fCash, and withdrawing cash. Like Notional V1, Notional V2 batches these actions up into a single transaction to give the user a better experience and reduce their gas costs. This post will walk through the batchBalanceAndTradeAction method.

The BalanceActionWithTrades parameter is an array of structs that specifies what actions the user wants to take on every currency.

For the rest of the post, I will walk through the steps of batchBalanceActionAndTrade. Just note that there are other methods like batchBalanceAction, settleAccount, depositUnderlyingToken, depositAssetToken, and withdraw (the latter four methods are in AccountAction.sol) that function in much the same way. The website will tend to use batchBalanceActionAndTrade so that is what's described here.

Settlement

Account Context and Settlement will be described in depth in their own blog posts but I'll quickly summarize what is happening here.  

When an account has fCash that is matured (i.e. it's maturity date is in the past), we convert that fCash into a cash balance. A lender will have a positive cash balance which they can withdraw. A borrower will have a negative cash balance that they have to repay. Since cash balances are denominated in "asset cash" that accrues interest, lenders and borrowers will be earning (or paying) a variable rate of interest after maturity. Settlement is a larger topic that will get it's own post, but it's sufficient to say that before any trading occurs we must settle fCash assets if they have matured.

Balance State

Each currency in Notional V2 is identified by a unique integer rather than a token address. A uint16 takes up much less storage space than a token address and also allows currencies to be easily sorted which is useful in some contexts. For example, on these lines we require that inputs to the batchBalanceActionAndTrade method are sorted by currencyId.

In _preTradeActions, the balanceState object is loaded with the account's balance of a the specified currency and we apply any settled cash amounts accordingly. The requirement for sorted currency ids allows us to only require one pass through the settleAmounts array (which is also sorted by currencyId) to apply settled cash balances.

Deposit Actions

Similar to how Compound wraps collateral into cTokens to ensure that collateral generates yield, Notional V2 wraps deposits into either "asset cash" (cTokens) or nTokens (Notional V2's liquidity token). DepositActionType specifies how a user's deposit should be handled.

Note that depositActionAmount is specified in different "precision" depending on the DepositActionType specified. Notional V2 tracks all internal balances with 8 decimal places. This significantly reduces the storage space required to track balances (18 decimal places balances require larger storage slots) and also reduces storage loads which would be required to determine the decimal places of different currencies (i.e. 6 decimals for USDC vs 18 decimals for DAI). When the code base refers to "internal precision" it means that it expects a particular value to be denominated in 8 decimal places. Conversely, "external precision" means that it expects the value to be denominated in the currency's native decimal precision (i.e. 6 for USDC, 18 for DAI).

Note that Notional V2 will never hold underlying tokens (i.e. DAI). When a user deposits an underlying token like DAI, it will be immediately wrapped to it's "asset cash" equivalent (i.e. cDAI). Also note that when depositing "asset cash" tokens like cDAI, the transfer is recorded in memory and deferred until balanceState.finalize is called later in the transaction. This is a bit of a gas optimization to ensure that only one ERC20 token transfer occurs on the net amount of tokens for the entire transaction (rather than one for deposit and one for withdraw).

This post won't discuss minting of nTokens, that will be covered in a subsequent post. It may seem odd that RedeemNToken is considered a DepositActionType since redeeming nTokens is analogous to withdrawing liquidity, however, having this happen before trading occurs in the next step ensures that the resulting cash balance is available to users for trading.

Trade Action

After deposits, we execute fCash trades via the TradingAction library. There are two flavors of fCash trading depending on the account's portfolio type which is out of scope for this post. There are also other specialized trading actions such as PurchaseNTokenResidual and SettleCashDebt which are out of scope for this post. They will be discussed in a post on settlement and market initialization.

In this section we'll focus on executeTradesArrayBatch which is similar enough to executeTradesBitmapBatch to communicate the idea. In this method, we iterate over each tightly packed bytes32 element in the bytes32[] trades array. Each trade is defined differently depending on the type in the first byte, the TradeActionType. How the bytes are packed is described here:

  • tradeActionType: first byte (big endian) which defines how the rest of the bytes will be decoded.
  • marketIndex: a numerical id between 1 to 7 that defines the tenor of the fCash market being traded.
  • fCashAmount: a uint88 amount of fCash to be traded. Note that this is a positive amount regardless of whether the trade is a lend or borrow.
  • assetCashAmount: a uint88 amount of cash to be deposited into an fCash Market when providing liquidity.
  • minImpliedRate or maxImpliedRate defines the minimum or maximum rate the trade will occur. This allows users to blunt the effects of front running their trades. If set to zero then no rate limit will be applied. When providing liquidity, both values can be set .

Each trade is executed in the order specified and will accumulate a net cash position in the specified currency. The reason we chose to structure the code this way is to allow users to trade across multiple fCash Markets in a single transaction. This allows a user to create trades where they borrow at a 1 year fCash market to lend at 3 month fCash market for example. These types of trades can give the user a lot of leverage for speculating on interest rates.

Liquidity Trade

Since Notional V2 allows users to provide liquidity passively using nTokens, most users will not opt to provide liquidity directly to markets. However, there are scenarios where users may find it more profitable to provide liquidity directly to markets and so these methods are made available.

_executeLiquidityTrade is fairly straightforward. It loads a market object into memory, updates the state in memory via addLiquidity or removeLiquidity, and updates the user's portfolio. Note that when removing liquidity, a negative amount of liquidity tokens is added to the portfolio (without allowing the portfolio's tokens to go negative).

The following lines of code are worth a bit more color:

Specifying a zero cashAmount when providing liquidity will dump all the accumulated net cash into the specified market as liquidity. A couple use cases for this would be removing liquidity in one market to "roll" it into another one. Another might be to borrow from one market to provide liquidity to another.

Lend or Borrow Trade

Most of the complexity with lending and borrowing on Notional V2 is abstracted inside the calculateTrade method. See part 1 of this series for an in depth dive into how fCash trades are calculated. Otherwise, _executeLendBorrowTrade is pretty straightforward.

Updating Portfolio State

All trades calculated above are done in memory in the TradingAction library. executeTradesArrayBatch returns the updated in memory PortfolioState and the aggregate netCash required from the trades.  A check is done to ensure that the account actually has enough cash to pay for its trades and then the account balances are updated accordingly.

After all currencies have been processed, the portfolio and account context are updated in the storeAssetsAndUpdateContext method call.

It's worth noting that if the executeTradesBitmapBatch method had been called instead, the account's portfolio would have been updated in storage inside the TradingAction library. Without going into more depth, bitmap portfolios can hold more fCash assets of a single currency than an array portfolio. They are also restricted from holding liquidity tokens. We will go into more depth on bitmap portfolios in a post on Advanced Trading in Notional V2.

Withdraws

Withdraws in each currency are defined by these three parameters:

  • withdrawAmountInternalPrecision: This specifies the amount of cash to be withdrawn unless withdrawEntireCashBalance is set to true.
  • withdrawEntireCashBalance: As discussed in the fCash Markets post, fCash Markets only calculate exchange rates from fCash to cash amounts. Because transactions are finalized asynchronously and depend on the block time, it's not possible to predetermine exactly how much cash will be lent or borrowed in a given transaction. Setting this field to true will cause any residual cash balance to be withdrawn back to the user's wallet. For example, when lending 110 fDAI, the user deposits 100.5 DAI (perhaps expecting that it actually requires 100 DAI). Perhaps the trade finalizes at 100.025 DAI, the remaining 0.475 DAI will be withdrawn back to the user's wallet, leaving them with exactly 110 fDAI and 0 DAI in Notional V2. A similar dynamic will occur when borrowing.
  • redeemToUnderlying: This is used in conjunction with withdrawEntireCashBalance to specify if the amount withdrawn should be redeemed to the underlying token or not. As discussed in the fCash Markets post, all balances in Notional V2 are held in money market funds (such as Compound) so users accrue passive interest. redeemToUnderlying will convert cTokens to the underlying token before transferring to the user.

The balanceState.finalize method call will take the following steps:

  • Ensure that nToken balances cannot become negative.
  • Ensure that cash balances can only become negative as a result of settlement.
  • Finalize ERC20 transfers as required, redeeming to underlying tokens if necessary. Note that we must be careful to ensure that amounts are denominated in the correct internal or external decimal precision at each step.
  • Mint incentives if the nToken balance changes. The actual incentive calculation will be discussed in the nToken post.
  • Update contract storage for nToken and cash balances.
  • Set a flag denoting if a currency is "active". An account may have up to 10 currencies that they actively hold fCash, nToken or cash balances in. If the balances are reduced to zero during this method, this currency is marked as "not active in balances" in the active currencies list. If the account also does not hold actively hold fCash or liquidity tokens in that currency, then the currency is removed from the list. A more in depth discussion of the Account Context will be in a separate post.
  • If the cash balance is negative, set the hasDebt flag to true for HAS_CASH_DEBT. This flag is used to check if an account requires a free collateral check.

Finalizing the Transaction

Before completing the transaction, we update any settleAmount balances that have not been net off in _preTradeActions and call setAccountContext to ensure that all account state is properly updated. The final step is to check if the account has any debt and do a free collateral check.

A free collateral check will determine if an account can complete the transaction given it's current collateral. Since a free collateral check can cost upwards of 40k gas we only run it for accounts that have debt. Since we don't scan all the account's assets to determine this, it is absolutely critical that the hasDebt flag is always set properly. The mechanics of the free collateral check are discussed in a separate post on the Notional V2 valuation framework.

Wrapping Up

This post discussed how complex trades can be accomplished on Notional V2 in a single transaction. If batchBalanceActionAndTrade does more than the user requires, a la carte methods are available for just depositing or withdrawing assets. Because we cannot calculate exactly how much net cash an account will have after lending or borrowing fCash, batchBalanceActionAndTrade ensures that the user's account can remain free of gas inefficient "dust" amounts.