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
: auint88
amount of fCash to be traded. Note that this is a positive amount regardless of whether the trade is a lend or borrow.assetCashAmount
: auint88
amount of cash to be deposited into an fCash Market when providing liquidity.minImpliedRate
ormaxImpliedRate
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 unlesswithdrawEntireCashBalance
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 totrue
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 withwithdrawEntireCashBalance
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 forHAS_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.