How Frax beats the Rest

How Frax beats the Rest

Written by jackchong.eth and 0xkowloon.eth

Stablecoins represent a large portion of the cryptocurrency’s total market capitalization, with a market size of $180 billion+. The simplest definition of a stablecoin is a digital currency with a value that's pegged to a ‘stable’ reserve asset, usually the U.S. dollar. For institutions, stablecoin is a wedge into the broader digital asset market: providing a higher yield and faster settlements to fiat whilst retaining the benefits of low volatility.

As much as crypto promises decentralization, stablecoins are still dominated by centralized products. 80%+ of the market is represented by fiat-backed assets such as USDC and USDT. These products are issued by centralized entities: Circle and Tether Holdings respectively.

Increasingly centralized issuers have been under greater regulatory scrutiny. Driven by the Tether scandal, Congress, Feds, and the Treasury have all eyes on stablecoins. In a recent public report, the Feds ever considers stablecoins a ‘collateral sinkhole’ that challenges monetary policy and financial system security.

From the Block
From the Block

As centralized stablecoins come under attack, decentralized stablecoins have become more attractive. In December 2020, fiat-backed stablecoins market capitalization was 21x that of algorithmic. Today, that ratio is just 5x.

This article is a deep-dive on Frax Finance, an algorithmic-backed stablecoin. Its market capitalization has grown nearly 530% since October 2021, $2 billion in total protocol value, making it currently the fastest-growing protocol.

The task of building an anti-fragile algorithmic-backed stablecoin is very difficult. There are three core competencies that need to be satisfied:

  1. Stable (low volatility)
  2. Maintains a peg to a chosen asset over a period of time
  3. High utility (integration with the wider ecosystem)

In his book Civilization: The West and the Rest, the historian Niall Ferguson attributed six reasons why the West is more economically developed than the rest of the world (’the Rest’). He named those factors, e.g. competition, property rights, and medicine, as ‘Killer Apps’.

Haseeb Qureshi at Dragonfly called Layer 1 cities. Balaji called a digitally native country a ‘crypto-civilization’. Here, we call Frax a Civilization like the West, a tribute to Niall Ferguson. You hear it from us first.

We have identified 3 Killer Apps that enable Frax to beat the Rest This article is also the first technical deep dive into Frax’s codebase.

Read on to find out how Frax beats the Rest!

This deep-dive concerns Frax v2 for protocol design and v3 contract for technical deep dive

What is Frax?

Under the typology of stablecoins (which there is no consistent paradigm at the moment!), Frax can be categorized as a ‘partially crypto-backed stablecoin’, with the following properties:

  • Crypto-backed: Backed by crypto assets rather than fiat.
  • Partially Collateralized: The value of the coins being minted is partially backed by a vault of assets. Overcollateralized coins such as DAI or completely uncollateralized coins such as the now defunt Basis.
  • Decentralized- Community governed and not owned by a central entity (e.g. Tether is owned by Tether Holdings, USDC is owned by Circle).
  • Dual-token model: The protocol also issues a secondary token $FXS, which is used for protocol governance and receives a percentage of profit the protocol generates from its activity.
  • Pegged to $1 nominal USD, rather than free-floating (e.g. RAI).

Collateral Ratio

From Messari
From Messari

Collateral Ratio (CR) determines how much collateral is needed in order for users to mint one $FRAX (CR is 85% at the time of writing). $FRAX is the stablecoin that is pegged to $1 USD. On the other hand, $FXS is the governance token of Frax Finance. Together, they form the seigniorage model of stablecoin.

What this means is that in order to mint $1 off $FRAX, $0.85 worth of $USDC needs to be deposited (or other accepted collateral) and $0.15 worth of $FXS. Conversely, if you want to redeem your $FRAX, you receive $0.85 worth of $USDC and $0.15 worth of $FXS.

Frax CR is a dynamic ratio. CR fluctuates according to market forces of demand and supply. It changes according to the $FRAX expansion and retraction. When demand for $FRAX drives up, CR decreases. Less collateral and more $FXS are required to mint $FRAX.

From Dragonfly Capital
From Dragonfly Capital

Since Frax’s CR mechanism mirrors how Central Banks work, analysts have compared Frax to an on-chain ‘automatic central bank’.

Now that we have an understanding of the Frax basics, let's look at Frax’s killer apps!

How Frax beats the Rest: 3 Killer Apps

Killer App 1: Everyone wants to be Curve

The hottest development in DeFi right now is The Curve Wars.

The Curve Wars refers to the competition between protocols for liquidity on the automated market maker (AMM) Curve Finance. It is the most popular decentralized exchange for swaps with nearly $18 billion TVL. Its main differentiator is low slippage and fees compared to competitors.

Why is the battleground on Curve, not Uniswap or another DEX? This is due to Curve’s protocol design around its token: $CRV. There are two hallmarks of Curve tokenomics:

1. vote-escrowed model: when you provide liquidity to a trading pool on Curve, you earn a share of all the trading fees on that pool. Additionally, you earn some $CRV, as a bonus incentive for providing liquidity. By staking and ‘locking up’ your $CRV you receive $veCRV. It grants token holders governance rights.

2. Gauge system: $veCRV token holders can vote on how much $CRV rewards each liquidity pool receives, or ‘Gauge weights’ in Curve’s terminology. The more votes a pool gets, the more $CRV rewards are directed to liquidity providers in that pool. More attractive rewards drive more liquidity providers to stake into the incentivized pool.

Why is this important? For any DeFi protocols, liquidity is king. And Curve owns one of the deepest liquidity with $18 billion TVL. A nascent protocol can deposit its native tokens and a pair of token (e.g. $FRAX - $ETH) into the pool so that liquidity increases. Alternatively, a protocol can ‘cheat’ and find some ways to increase $CRV rewards of their pools. Other people will see that high APY and naturally become liquidity providers.

Complexity emerges from simple rules.

Convex Finance emerges as a $CRV aggregator. Here’s how Convex works:

  1. You deposit your $CRV into Convex, in exchange for $cvxCRV.
  2. Convex goes and stakes your $CRV on Curve for $veCRV.
  3. Convex earns rewards from Curve. It redistributes them to $cvxCRV stakers.
  4. $cvxCRV is also immediately liquid with no lock-up, unlike Curve.
Follow where the CRV LPs flow
Follow where the CRV LPs flow

Convex becomes like Curve itself, but with the added benefit of liquidity. Because of this, it has since accumulated the majority of CRV tokens. Now it controls 52% of voting power on Curve and locks 200 million $CRV for $veCRV since inception. For protocols looking to incentivize liquidity, they either buy $CVX tokens or ‘bribe’ $CVX token holders to vote for their protocols.

The screenshot above is taken from Votium, a bribing platform. It shows the costs to bribe $1 worth of $vlCVX, which is a vote-locked Convex token that allows its holders to vote on Curve’s gauge weights with Convex’s $veCRV holdings. Protocols bribe CVX holders to vote in favor of their liquidity pools. With a higher gauge weight, Curve gives out a higher APY reward. With higher APY, the liquidity pools can attract more liquidity.

Winning on Curve’s turf

Frax has been winning on Curve’s turf.

As the largest holder of $CVX, Frax directs its share of Convex’s pool of $veCRV to direct more $CRV rewards to $FRAX denominated pools.

In addition, FRAX also pays the most bribes on Votium each week compared to any other stablecoin protocol.

With better rewards (higher APY), Frax attracts more liquidity providers, driving further adoption. Deeper liquidity for Frax ensures the peg becomes more stable and easier to defend.

The largest Frax pool on Curve is the FRAX3CRV pool, which allows users to swap $FRAX against the three major stables USDT, USDC, and DAI. With ~ 1.5 billion of $FRAX deposited, large swaps in the pool have minimal price impact. This affords $FRAX a buffer against tail-risked sell pressure.

In other words, liquidity begets liquidity.

If you can’t beat them, become them

Curve’s dominance in the liquidity wars is undeniable. Strategically then, every protocol should strive to be like Curve. Ideally, every protocol wants other protocols to compose on top of you.

However, Lindy effect dictates that Curve will probably remain unchallenged for a while. So if you can’t beat Curve, become Curve!

To do so, Frax has adopted Curve’s tokenomics model by implementing the vote escrow governance model, where $FXS holders can lock up their tokens in return for $veFXS.

While $veCRV holders dictate gauge weights on Curve emissions for specific pools on Curve itself, $veFXS holders vote on gauges that direct $FXS emissions across different pools on different DEXes. For example, the largest pool is Uniswap V3 $FRAX/$USDC with more than 45% of emission rewards.

Recently, Convex has partnered with Frax protocol to further align the two protocols' incentive programs, specifically around locking veFXS into the CVX protocol. Convex sees itself beyond just an aggregator for veCRV. It has a bigger ambition: a voting power aggregator for all kinds of ve-tokens.

By using $FXS emissions, Frax is able to deepen liquidity by incentivizing liquidity providers to stake into $FRAX denominated pools, much in the same way as Curve. In order to maintain the slice of $FXS emissions being directed to their pool, liquidity providers stake their $FXS rewards for $veFXS’ governance power. As $FRAX permeates into more protocols and swap pairs, its total market capitalization increases. When $FRAX’s dominance increases, $FXS emissions become more valuable. Soon every protocol wants to own some slices of $FXS gauge voting to direct incentives to their liquidity pair!

In other words, the Frax War is the meta-Curve Wars. Money lego turns into a baseplate.

Second, there is currently a battle amongst protocols for their token to be the liquidity pairing of choice for new and emerging DAOs. We like to refer to this as the ’DAO Liquidity Wars’. Players competing in this space include Rift Finance, Fei & Rari, and OlympusDAO amongst other emerging protocols.

If the Curve Wars is about liquidity, the DAO Liquidity War is about utility.

Every stablecoin aspires to be the denominating currency of choice throughout the ecosystem. If Frax wins the DAO Liquidity War, it locks an emerging DAO into an interdependent relationship by offering $FRAX as one side of the liquidity pair.

As more and more pairs use $FRAX, its utility increases. When $FRAX becomes a major reserve currency of choice for DAOs, it starts to become the USD equivalent on-chain. The winner of the DAO Liquidity War is crowned as the trusted denomination by which the whole ecosystem conducts businesses and transactions.

The loot of the DAO Liquidity War is protocol hegemony.

Killer App 2: The Frax Flex

Frax flexes its peg

Since its launch, $FRAX has never gone off its peg. This is quite remarkable compared to its peers of algorithmic stablecoins (e.g. Iron Finance and Fei).

We attribute its strong peg to the dual-token flywheel. $FXS is designed to be a complimentary token to $FRAX. It absorbs the volatility of the dollar-pegged $FRAX while distributing protocol value to its token holders. The flywheel works as follows:

  1. $FXS supply is capped at 100 million tokens
  2. Channel protocol revenue to $FXS token holders via buyback and burning
  3. Set $FXS and $USDC as collaterals for FRAX
  4. Make $FRAX indispensable in DeFi, increasing revenue and boosting $FXS price
  5. Collateral Ratio drives down as $FXS becomes more valuable
  6. Eventually, $FRAX becomes fully backed to $FXS without any collaterals of other stablecoins, let alone the centralized $USDC

The paradox here is that to build a resilient fully algorithmic stablecoin, you need to begin with partial-collateralization. Eventually, you might even end up with over-collateralization! This non-trivial roadmap is similar to the Web2 strategy of ‘attacking from below’.

Frax Price Index (FPI)

Frax has been stacking inflation resistance on top of its dollar-pegged stablecoin. On 1 January 2022, Sam, the co-founder, tweeted that the Frax team is working on the Frax Price Index ($FPI), a new native stablecoin.

$FPI would be pegged to a decentralized consumer price index (CPI) with crypto native elements added on top of Chainlink’s custom CPI oracle built for FRAX. Token holders see an increase in their token’s dollar-denominated value each month according to the reported CPI increase. This is possible because Frax earns yield on the underlying FPI treasury, created from users minting and redeeming FPI with FRAX.

The peg mechanism is unclear at the moment, but from the preliminary documents, $FPI has properties similar to a money market fund: a deposit balance with a guaranteed quarter yield that is highly liquid. Maintaining this peg on-chain, however, might require some innovative peg mechanisms. Speaking at a Twitter Space event, Sam said that all volatility is smoothed out over a year by taking a trailing 12 months (TTM) CPI. $FPI is also backstopped by deep liquidity provided by Frax and Fei. It aims to be the unit of exchange for DAO contributor pay. Ultimately, the goal is to create a crypto-native CPI that represents the median living cost globally with a set basket of goods.

While we wait for more details, this is a really exciting feature that might initiate Stablecoin 2.0.

We are cautiously optimistic: finally, stable coins manifest the true destiny of programmable money. Go off-peg with the nominal $USD!

Killer App 3: Algorithmic Market Operations (AMO)

On March 2021, Frax v2 unveiled its finest innovation to date, the Algorithmic Market Operations (AMOs).

To understand how this works, let’s look at the equivalent in the fiat world.

Central banks, such as the Federal Reserve, control the money supply by engaging in “Open Market Operations”. By buying (or selling) Treasury bonds from the market, the Fed effectively increases (or decreases) the total circulating amount of USD in the monetary system.

AMOs use a similar strategy. The Frax protocol influences the supply of $FRAX across the DeFi ecosystem. Further, anyone in the community can also propose an AMO strategy via governance.

With any AMO strategies we can break them down into four key components- Decollateralize, Market Operations, Recollateralize and Burn.

Let’s walk through the Curve AMO strategy:

  1. Decollateralize - the algorithm determines how much excess collateral, in $USDC and $FRAX, is currently sitting idle the Frax protocol’s Treasury. Excess collateral refers to the extra amount of assets above the Collateral Ratio required to back the total supply of $FRAX.
  2. Market operations - the AMO injects the idle $USDC and $FRAX into the FRAX3CRV Curve pool, by doing so deepening the liquidity and strengthening the dollar peg.
  3. Recollateralize - when the treasury is getting too close to the collateral ratio, the AMO will remove liquidity from the Curve3pool and recollateralize the protocol.
  4. Burn- Using revenue accrued from swap fees from deploying excess collateral into LPs, the protocol is able to mint new $FRAX at the collateralization ratio and subsequently buyback $FXS to be burnt. this is managed by the FXS1559 Standard.

AMO earns LP rewards and trading fees for the protocol in a capital-efficient way. The profits are then distributed to $veFXS holders. The protocol also strengthens its peg by intervening in the market to provide liquidity for $FRAX stablecoin. This is similar to current central bank operations, such as the Hong Kong Monetary Authority, where foreign currency reserves are deployed to maintain a currency peg (e.g. HKD-USD).

Other stablecoins rely on arbitrageurs and speculators to maintain the peg. Frax takes matters into its own hands.

Fine, I will do it myself

Technical Deep Dive

To get a greater understanding of how each of these Killer Apps works, we conducted a deep dive into the protocol’s contract.

Frax Finance’s core logic lives in the pool contract, which controls when FRAX can be minted and redeemed. The current mainnet deployment is a USDC pool that only allows single collateral. But FRAX is moving towards a multi-collateral backed stablecoin, so we are going to look at the V3 version of the contract instead.

Minting FRAX

Anyone can mint FRAX by calling the function FraxPoolV3#mintFrax, as long as the following conditions are satisfied.

  1. The collateral type has to be supported. It is checked in the modifier collateralEnabled.
modifier collateralEnabled(uint256 col_idx) {
    require(enabled_collaterals[collateral_addresses[col_idx]], "Collateral disabled");
    _;
}

Only the contract owner and the timelock address have the right to enable/disable collaterals. A majority of the protocol’s privileged functions can only be called by the contract owner or on-chain governance through the timelock contract.

  1. Minting using the said collateral is not paused.

require(mintPaused[col_idx] == false, "Minting is paused");

  1. Current FRAX price is above the mint price threshold. The initial threshold is set to be $1.01. Given how the protocol wants to stabilize FRAX at around $1, it does not make sense to allow inflate token supply when the price is above $1.

require(getFRAXPrice() >= mint_price_threshold, "Frax price too low");

FRAX’s price in USD is retrieved from Chainlink. Most Chainlink price feeds return a number in 8 decimals. The function then trims it to 6 decimal places by multiplying it by PRICE_PRECISION (1e6) and then dividing it by 10 ** 8 (It is common to see decimals conversion in Solidity due to the absence of native decimals type. Also, always multiply first before division to avoid a loss in precision!).

function getFRAXPrice() public view returns (uint256) {
    (uint80 roundID, int price, , uint256 updatedAt, uint80 answeredInRound) = priceFeedFRAXUSD.latestRoundData();
    require(price >= 0 && updatedAt!= 0 && answeredInRound >= roundID, "Invalid chainlink price");

    return uint256(price).mul(PRICE_PRECISION).div(10 ** chainlink_frax_usd_decimals);
}

Required collateral calculation

The amount of collateral (USDC) required to back 1 FRAX depends on FRAX’s global collateral ratio. Before going straight to the collateral calculation, let’s take a detour and inspect how this global collateral ratio is adjusted.

The variable global_collateral_ratio lives in the Frax contract. It has the initial value of 1e7, meaning that each FRAX is fully backed by 1 USDC. This ratio can be adjusted every refresh_cooldown seconds (currently 1 hour) by calling the function refreshCollateralRatio. This function retrieves the price of FRAX in USD (this time not getting FRAX/USD price directly, but calculating FRAX/USD through FRAX/ETH and ETH/USD). The collateral ratio can only be adjusted if FRAX is trading outside of the price_band, which is currently set to 5000 (< $0.995 and > $1.005). If FRAX is trading within the price band, there is no reason for the protocol to destabilize the protocol by making adjustments.

It is perceived as acceptable to decrease the collateral ratio when FRAX is trading above the price band because the price reflects the confidence in the currency and vice versa. In order to not create price volatility by drastically changing the collateral ratio, the collateral ratio can only go up/down by frax_step(0.25%) in each refresh period. The code also makes sure the collateral ratio is between 0% and 100%.

uint256 public last_call_time; // Last time the refreshCollateralRatio function was called
function refreshCollateralRatio() public {
    require(collateral_ratio_paused == false, "Collateral Ratio has been paused");
    uint256 frax_price_cur = frax_price();
    require(block.timestamp - last_call_time >= refresh_cooldown, "Must wait for the refresh cooldown since last refresh");

    // Step increments are 0.25% (upon genesis, changable by setFraxStep()) 
    
    if (frax_price_cur > price_target.add(price_band)) { //decrease collateral ratio
        if(global_collateral_ratio <= frax_step){ //if within a step of 0, go to 0
            global_collateral_ratio = 0;
        } else {
            global_collateral_ratio = global_collateral_ratio.sub(frax_step);
        }
    } else if (frax_price_cur < price_target.sub(price_band)) { //increase collateral ratio
        if(global_collateral_ratio.add(frax_step) >= 1000000){
            global_collateral_ratio = 1000000; // cap collateral ratio at 1.000000
        } else {
            global_collateral_ratio = global_collateral_ratio.add(frax_step);
        }
    }

    last_call_time = block.timestamp; // Set the time of the last expansion

    emit CollateralRatioRefreshed(global_collateral_ratio);
}

Now going back to collateral calculation.

The collateral ratio, between 0-100%, determines the amount of USDC backing required to mint one FRAX. The remaining value is backed by the FXS token. For example, if the collateral ratio was 90%, 90c of USDC would be required to mint one FRAX and the remainder would be covered by 10c of FXS tokens. It also uses a Chainlink oracle to get FXS price.

if (one_to_one_override || global_collateral_ratio >= PRICE_PRECISION) { 
    // 1-to-1, overcollateralized, or user selects override
    collat_needed = getFRAXInCollateral(col_idx, frax_amt);
    fxs_needed = 0;
} else if (global_collateral_ratio == 0) { 
    // Algorithmic
    collat_needed = 0;
    fxs_needed = frax_amt.mul(PRICE_PRECISION).div(getFXSPrice());
} else { 
    // Fractional
    uint256 frax_for_collat = frax_amt.mul(global_collateral_ratio).div(PRICE_PRECISION);
    uint256 frax_for_fxs = frax_amt.sub(frax_for_collat);
    collat_needed = getFRAXInCollateral(col_idx, frax_for_collat);
    fxs_needed = frax_for_fxs.mul(PRICE_PRECISION).div(getFXSPrice());
}

Interestingly, the function getFRAXInCollateral relies on a manually set collateral price to calculate collateral required (most likely because it is only USDC as of now). collateral_prices[col_idx] is set by privileged accounts via the function setCollateralPrice. This could potentially be an issue down the road and an oracle should eventually replace this manual setting.

function getFRAXInCollateral(uint256 col_idx, uint256 frax_amount) public view returns (uint256) {
    return frax_amount.mul(PRICE_PRECISION).div(10 ** missing_decimals[col_idx]).div(collateral_prices[col_idx]);
}

Finally, the function mints FRAX to the user. It charges a small minting fee. The FXS backing the FRAX minted is burned. One last thing to note is that each collateral type has a ceiling and only privileged accounts have the ability to lift it.

total_frax_mint = (frax_amt.mul(PRICE_PRECISION.sub(minting_fee[col_idx]))).div(PRICE_PRECISION);

require(freeCollatBalance(col_idx).add(collat_needed) <= pool_ceilings[col_idx], "Pool ceiling");

FXS.pool_burn_from(msg.sender, fxs_needed);

TransferHelper.safeTransferFrom(collateral_addresses[col_idx], msg.sender, address(this), collat_needed);

FRAX.pool_mint(msg.sender, total_frax_mint);

Redeeming FRAX

Anyone can deposit FRAX to redeem USDC/FXS by calling FraxPoolV3#redeemFrax. The mechanism (including the condition checks) is similar to minting FRAX in which the final outputted USDC/FXS amounts are also dependent on the global collateral ratio.

// Prevent unnecessary redemptions that could adversely affect the FXS price
require(getFRAXPrice() <= redeem_price_threshold, "Frax price too high");

// There is a redemption fee, like the minting fee.
uint256 frax_after_fee = (frax_amount.mul(PRICE_PRECISION.sub(redemption_fee[col_idx]))).div(PRICE_PRECISION);

// Assumes $1 FRAX in all cases
if(global_collateral_ratio >= PRICE_PRECISION) { 
    // 1-to-1 or overcollateralized
    collat_out = getFRAXInCollateral(col_idx, frax_after_fee);
    fxs_out = 0;
} else if (global_collateral_ratio == 0) { 
    // Algorithmic
    fxs_out = frax_after_fee
                    .mul(PRICE_PRECISION)
                    .div(getFXSPrice());
    collat_out = 0;
} else { 
    // Fractional
    collat_out = getFRAXInCollateral(col_idx, frax_after_fee)
                    .mul(global_collateral_ratio)
                    .div(PRICE_PRECISION);
    fxs_out = frax_after_fee
                    .mul(PRICE_PRECISION.sub(global_collateral_ratio))
                    .div(getFXSPrice()); // PRICE_PRECISIONS CANCEL OUT
}

The pool needs to have sufficient collateral to be withdrawn.

require(collat_out <= (ERC20(collateral_addresses[col_idx])).balanceOf(address(this)).sub(unclaimedPoolCollateral[col_idx]), "Insufficient pool collateral");

The redeem mechanism is not a straightforward “deposit FRAX and get USDC/FXS immediately” because Frax Finance wants to prevent flash loan attacks (take out FRAX/collateral from the system, use an AMM to trade the new price, and then mint back into the system). There is a 2 blocks delay between the redemption and when the redeemer can actually collect the collaterals.

// Update the protocol's debt to the redeemer in USDC and FXS
redeemCollateralBalances[msg.sender][col_idx] = redeemCollateralBalances[msg.sender][col_idx].add(collat_out);
unclaimedPoolCollateral[col_idx] = unclaimedPoolCollateral[col_idx].add(collat_out);

redeemFXSBalances[msg.sender] = redeemFXSBalances[msg.sender].add(fxs_out);
unclaimedPoolFXS = unclaimedPoolFXS.add(fxs_out);

// The redemption delay starts from the mined block
lastRedeemed[msg.sender] = block.number;

Finally, FRAX is burned and FXS is minted to the pool (address(this) and not msg.sender! remember the block delay!)

FRAX.pool_burn_from(msg.sender, frax_amount);
FXS.pool_mint(address(this), fxs_out);

Collecting collaterals

After 2 blocks have elapsed, redeemers who are owed collaterals can collect them by calling FraxPoolV3#collectRedemption.

// 2 blocks must have elapsed
require((lastRedeemed[msg.sender].add(redemption_delay)) <= block.number, "Too soon");

The function checks that there are really tokens to be collected and does not make safeTransfer calls if there aren’t any to be sent (probably gas optimization).

bool sendFXS = false;
bool sendCollateral = false;

// Use Checks-Effects-Interactions pattern
if(redeemFXSBalances[msg.sender] > 0){
    fxs_amount = redeemFXSBalances[msg.sender];
    redeemFXSBalances[msg.sender] = 0;
    unclaimedPoolFXS = unclaimedPoolFXS.sub(fxs_amount);
    sendFXS = true;
}

if(redeemCollateralBalances[msg.sender][col_idx] > 0){
    collateral_amount = redeemCollateralBalances[msg.sender][col_idx];
    redeemCollateralBalances[msg.sender][col_idx] = 0;
    unclaimedPoolCollateral[col_idx] = unclaimedPoolCollateral[col_idx].sub(collateral_amount);
    sendCollateral = true;
}

// Send out the tokens
if(sendFXS){
    TransferHelper.safeTransfer(address(FXS), msg.sender, fxs_amount);
}
if(sendCollateral){
    TransferHelper.safeTransfer(collateral_addresses[col_idx], msg.sender, collateral_amount);
}

FXS buybacks

When the protocol is holding more collateral than is required, FXS holders can call FraxPoolV3#buybackFxs to pump everyone’s bags.

The protocol needs to first calculate the excess collateral value in the system. The theoretical amount of excess collateral available for buybacks is simply the difference between the current collateral value in the protocol and the collateral value required by the current collateral ratio. But realistically it is not a good idea to take out all the excess collaterals due to volatility, especially as Frax plans to introduce non-stable collateral types in the future. By getting to right at the collateral line, Frax can immediately become undercollateralized. For this reason, Frax implements an hourly limit on how much collateral can leave the protocol (bbkMaxColE18OutPerHour - buyback max collateal 1e18 out per hour).

The function comboCalcBbkRct calculates the actual available FXS amount for buybacks given the utilized hourly limit, max hourly limit, and the theoretical hourly limit.

function buybackAvailableCollat() public view returns (uint256) {
    uint256 total_supply = FRAX.totalSupply();
    uint256 global_collateral_ratio = FRAX.global_collateral_ratio();
    uint256 global_collat_value = FRAX.globalCollateralValue();

    if (global_collateral_ratio > PRICE_PRECISION) global_collateral_ratio = PRICE_PRECISION; // Handles an overcollateralized contract with CR > 1
    uint256 required_collat_dollar_value_d18 = (total_supply.mul(global_collateral_ratio)).div(PRICE_PRECISION); // Calculates collateral needed to back each 1 FRAX with $1 of collateral at current collat ratio
    
    if (global_collat_value > required_collat_dollar_value_d18) {
        // Get the theoretical buyback amount
        uint256 theoretical_bbk_amt = global_collat_value.sub(required_collat_dollar_value_d18);

        // See how much has collateral has been issued this hour
        uint256 current_hr_bbk = bbkHourlyCum[curEpochHr()];

        // Account for the throttling
        return comboCalcBbkRct(current_hr_bbk, bbkMaxColE18OutPerHour, theoretical_bbk_amt);
    }
    else return 0;
}

It then makes sure the dollar value of the FXS to burn is not greater than the excess collateral value so that the collateral ratio is not breached.

uint256 fxs_dollar_value_d18 = fxs_amount.mul(fxs_price).div(PRICE_PRECISION);
require(fxs_dollar_value_d18 <= available_excess_collat_dv, "Insuf Collat Avail For BBK");

The collateral value to be removed is calculated based on the FXS’s dollar value. A buyback fee is charged by the protocol. missing_decimals is an array to account for the difference in decimals between FRAX (18) and its collaterals (USDC - 6). It is set during contract construction.

// In the constructor...
missing_decimals.push(uint256(18).sub(ERC20(_collateral_addresses[i]).decimals()));
// Get the equivalent amount of collateral based on the market value of FXS provided 
uint256 collateral_equivalent_d18 = fxs_dollar_value_d18.mul(PRICE_PRECISION).div(collateral_prices[col_idx]);
col_out = collateral_equivalent_d18.div(10 ** missing_decimals[col_idx]); // In its natural decimals()

// Subtract the buyback fee
col_out = (col_out.mul(PRICE_PRECISION.sub(buyback_fee[col_idx]))).div(PRICE_PRECISION);

FXS is burned, collateral transferred to the burner and the hourly buyback counter value is updated.

// Take in and burn the FXS, then send out the collateral
FXS.pool_burn_from(msg.sender, fxs_amount);
TransferHelper.safeTransfer(collateral_addresses[col_idx], msg.sender, col_out);

// Increment the outbound collateral, in E18, for that hour
// Used for buyback throttling
bbkHourlyCum[curEpochHr()] += collateral_equivalent_d18;

Re-collateralization

When FRAX’s collateral backed value is below the collateral ratio, the vault needs to be topped up for the protocol to stay healthy. In order to incentivize people to help re-collateralize, the protocol gives a bonus in FXS, defined by the bonus_rate.

Similar to buybacks, there is an hourly limit on the available amount for re-collateralization so the actual value will be smaller than the theoretical available value.

function recollatAvailableFxs() public view returns (uint256) {
    uint256 fxs_price = getFXSPrice();

    // Get the amount of collateral theoretically available
    uint256 recollat_theo_available_e18 = recollatTheoColAvailableE18();

    // Get the amount of FXS theoretically outputtable
    uint256 fxs_theo_out = recollat_theo_available_e18.mul(PRICE_PRECISION).div(fxs_price);

    // See how much FXS has been issued this hour
    uint256 current_hr_rct = rctHourlyCum[curEpochHr()];

    // Account for the throttling
    return comboCalcBbkRct(current_hr_rct, rctMaxFxsOutPerHour, fxs_theo_out);
}

The theoretical available amount is the difference between the minimum collateral value (collateral ratio x FRAX total supply) and the effective collateral value (actual collateral ratio based on collateral value x FRAX total supply).

function recollatTheoColAvailableE18() public view returns (uint256) {
    uint256 frax_total_supply = FRAX.totalSupply();
    uint256 effective_collateral_ratio = FRAX.globalCollateralValue().mul(PRICE_PRECISION).div(frax_total_supply); // Returns it in 1e6
    
    uint256 desired_collat_e24 = (FRAX.global_collateral_ratio()).mul(frax_total_supply);
    uint256 effective_collat_e24 = effective_collateral_ratio.mul(frax_total_supply);

    // Return 0 if already overcollateralized
    // Otherwise, return the deficiency
    if (effective_collat_e24 >= desired_collat_e24) return 0;
    else {
        return (desired_collat_e24.sub(effective_collat_e24)).div(PRICE_PRECISION);
    }
}

The protocol gives a bonus, but at the same time also charges a re-collateralization fee. The final FXS amount leaving the protocol has to be ≤ the available FXS for re-collateralization.

fxs_out = collateral_amount_d18.mul(PRICE_PRECISION.add(bonus_rate).sub(recollat_fee[col_idx])).div(fxs_price);

// Make sure there is FXS available
require(fxs_out <= fxs_actually_available, "Insuf FXS Avail For RCT");

It also makes sure the pool ceiling is not breached after the collateral deposit.

require(freeCollatBalance(col_idx).add(collateral_amount) <= pool_ceilings[col_idx], "Pool ceiling");

Finally it takes the collateral, sends the msg.sender FXS and updates the hourly re-collateralization counter.

// Take in the collateral and pay out the FXS
TransferHelper.safeTransferFrom(collateral_addresses[col_idx], msg.sender, address(this), collateral_amount);
FXS.pool_mint(msg.sender, fxs_out);

// Increment the outbound FXS, in E18
// Used for recollat throttling
rctHourlyCum[curEpochHr()] += fxs_out;

Algorithmic Market Operations Controller

Algorithmic Market Operations Controller, or AMO minters, can borrow collaterals to deploy excess collateral to Algorithmic Market Operations (AMOs).

function amoMinterBorrow(uint256 collateral_amount) external onlyAMOMinters {
        // Checks the col_idx of the minter as an additional safety check
        uint256 minter_col_idx = IFraxAMOMinter(msg.sender).col_idx();

        // Checks to see if borrowing is paused
        require(borrowingPaused[minter_col_idx] == false, "Borrowing is paused");

        // Ensure collateral is enabled
        require(enabled_collaterals[collateral_addresses[minter_col_idx]], "Collateral disabled");

        // Transfer
        TransferHelper.safeTransfer(collateral_addresses[minter_col_idx], msg.sender, collateral_amount);
    }

An AMO minter is a privileged role that can only be added by the contract owner or governance through the function addAMOMinter. An AMO minter must have the function collatDollarBalance defined.

uint256 collat_val_e18 = IFraxAMOMinter(amo_minter_addr).collatDollarBalance();
require(collat_val_e18 >= 0, "Invalid AMO");

The contract FraxAMOMinter is the implementation of AMO minter. It holds an array of destination AMOs under its control and it runs its internal accounting to keep track on how much has been borrowed. When a destination AMO wants to borrow, it can call giveCollatToAMO. The function checks that the collateral borrow cap is not reached, adds the borrow amount to the destination AMO’s balance, asks the pool to provide the collateral, transfers the collateral to the destination AMO and sync all AMOs’ total FRAX and collateral balances through syncDollarBalances.

function giveCollatToAMO(
    address destination_amo,
    uint256 collat_amount
) external onlyByOwnGov validAMO(destination_amo) {
    int256 collat_amount_i256 = int256(collat_amount);

    require((collat_borrowed_sum + collat_amount_i256) <= collat_borrow_cap, "Borrow cap");
    collat_borrowed_balances[destination_amo] += collat_amount_i256;
    collat_borrowed_sum += collat_amount_i256;

    // Borrow the collateral
    pool.amoMinterBorrow(collat_amount);

    // Give the collateral to the AMO
    TransferHelper.safeTransfer(collateral_address, destination_amo, collat_amount);

    // Sync
    syncDollarBalances();
}

syncDollarBalances loops through every AMO to get its FRAX and collateral balances. Each AMO must have a function called dollarBalances that returns a tuple of FRAX and collateral balances. The mapping correction_offsets_amos is used to fix incorrect balance accounting and can be set by contract owner or governance through setAMOCorrectionOffsets.

function syncDollarBalances() public {
    uint256 total_frax_value_d18 = 0;
    uint256 total_collateral_value_d18 = 0; 
    for (uint i = 0; i < amos_array.length; i++){ 
        // Exclude null addresses
        address amo_address = amos_array[i];
        if (amo_address != address(0)){
            (uint256 frax_val_e18, uint256 collat_val_e18) = IAMO(amo_address).dollarBalances();
            total_frax_value_d18 += uint256(int256(frax_val_e18) + correction_offsets_amos[amo_address][0]);
            total_collateral_value_d18 += uint256(int256(collat_val_e18) + correction_offsets_amos[amo_address][1]);
        }
    }
    fraxDollarBalanceStored = total_frax_value_d18;
    collatDollarBalanceStored = total_collateral_value_d18;
}

Specific AMOs

There are many different AMOs being developed in Frax Finance, but let’s look at one of them to see how it actually works. The contract InvestorAMO_V3 deposits USDC into Yearn, Aave and Compound to earn interest as well as to farm their tokens. To execute an excess collateral deployment, the contract owner or the time-locked governance has to execute the transaction through execute, which is just a generic proxy to pass arbitrary bytes to the _to address (it should be the AMO minter).

function execute(
    address _to,
    uint256 _value,
    bytes calldata _data
) external onlyByOwnGov returns (bool, bytes memory) {
    (bool success, bytes memory result) = _to.call{value:_value}(_data);
    return (success, result);
}

Once the AMO acquires USDC in its contract, privileged accounts can deploy USDC to the protocols.

function yDepositUSDC(uint256 USDC_amount) public onlyByOwnGovCust {
    require(allow_yearn, 'yearn strategy is currently off');
    collateral_token.approve(address(yUSDC_V2), USDC_amount);
    yUSDC_V2.deposit(USDC_amount);
}

function aaveDepositUSDC(uint256 USDC_amount) public onlyByOwnGovCust {
    require(allow_aave, 'AAVE strategy is currently off');
    collateral_token.approve(address(aaveUSDC_Pool), USDC_amount);
    aaveUSDC_Pool.deposit(collateral_address, USDC_amount, address(this), 0);
}

function compoundMint_cUSDC(uint256 USDC_amount) public onlyByOwnGovCust {
    require(allow_compound, 'Compound strategy is currently off');
    collateral_token.approve(address(cUSDC), USDC_amount);
    cUSDC.mint(USDC_amount);
}

The AMO’s dollar balances is the sum of its unallocated USDC plus the USDC deployed to the 3 protocols. This is the required function by AMO minter’s syncDollarBalances mentioned earlier.

function dollarBalances() public view returns (uint256 frax_val_e18, uint256 collat_val_e18) {
    frax_val_e18 = (showAllocations()[4]).mul(10 ** missing_decimals);
    collat_val_e18 = frax_val_e18;
}

function showAllocations() public view returns (uint256[5] memory allocations) {
    // All numbers given are assuming xyzUSDC, etc. is converted back to actual USDC
    allocations[0] = collateral_token.balanceOf(address(this)); // Unallocated
    allocations[1] = (yUSDC_V2.balanceOf(address(this))).mul(yUSDC_V2.pricePerShare()).div(1e6); // yearn
    allocations[2] = aaveUSDC_Token.balanceOf(address(this)); // AAVE
    allocations[3] = (cUSDC.balanceOf(address(this)).mul(cUSDC.exchangeRateStored()).div(1e18)); // Compound. Note that cUSDC is E8

    uint256 sum_tally = 0;
    for (uint i = 0; i < 4; i++){ 
        if (allocations[i] > 0){
            sum_tally = sum_tally.add(allocations[i]);
        }
    }

    allocations[4] = sum_tally; // Total USDC Value
}

Meanwhile, not only is the AMO earning yield on USDC. It is also earning COMP and AAVE! You can call showRewards to see how much the AMO has made and collect its rewards by calling various claim reward functions.

// 1. show rewards
// 2. claim rewards
// 3. transfer rewards out of the contract
function showRewards() external view returns (uint256[3] memory rewards) {
    // IMPORTANT
    // Should ONLY be used externally, because it may fail if COMP.balanceOf() fails
    rewards[0] = COMP.balanceOf(address(this)); // COMP
    rewards[1] = stkAAVE.balanceOf(address(this)); // stkAAVE
    rewards[2] = AAVE.balanceOf(address(this)); // AAVE
}

function compoundCollectCOMP() public onlyByOwnGovCust {
    address[] memory cTokens = new address[](1);
    cTokens[0] = address(cUSDC);
    CompController.claimComp(address(this), cTokens);
}

function aaveCollect_stkAAVE() public onlyByOwnGovCust {
    address[] memory the_assets = new address[](1);
    the_assets[0] = address(aaveUSDC_Token);
    uint256 rewards_balance = AAVEIncentivesController.getRewardsBalance(the_assets, address(this));
    AAVEIncentivesController.claimRewards(the_assets, rewards_balance, address(this));
}

function withdrawRewards() public onlyByOwnGovCust {
    COMP.transfer(msg.sender, COMP.balanceOf(address(this)));
    stkAAVE.transfer(msg.sender, stkAAVE.balanceOf(address(this)));
    AAVE.transfer(msg.sender, AAVE.balanceOf(address(this)));
}

When the yield rate is no longer satisfying, privileged accounts can pull the money out and return the collateral back to the protocol. receiveCollatFromAMO takes the USDC from the AMO, reduces the AMO’s borrowed balance and syncs all pools’ FRAX and collateral balances.

// 1. withdraw USDC from protocols
// 2. send collateral back through the AMO minter
function yWithdrawUSDC(uint256 yUSDC_amount) public onlyByOwnGovCust {
    yUSDC_V2.withdraw(yUSDC_amount);
}

function aaveWithdrawUSDC(uint256 aUSDC_amount) public onlyByOwnGovCust {
    aaveUSDC_Pool.withdraw(collateral_address, aUSDC_amount, address(this));
}

function compoundRedeem_cUSDC(uint256 cUSDC_amount) public onlyByOwnGovCust {
    // NOTE that cUSDC is E8, NOT E6
    cUSDC.redeem(cUSDC_amount);
}

function giveCollatBack(uint256 collat_amount) external onlyByOwnGovCust {
    collateral_token.approve(address(amo_minter), collat_amount);
    amo_minter.receiveCollatFromAMO(collat_amount);
}

// in FraxAMOMinter...
function receiveCollatFromAMO(uint256 usdc_amount) external validAMO(msg.sender) {
    int256 collat_amt_i256 = int256(usdc_amount);

    // Give back first
    TransferHelper.safeTransferFrom(collateral_address, msg.sender, address(pool), usdc_amount);

    // Then update the balances
    collat_borrowed_balances[msg.sender] -= collat_amt_i256;
    collat_borrowed_sum -= collat_amt_i256;

    // Sync
    syncDollarBalances();
}

Minting FRAX directly into AMOs

This function sets the FRAX protocol apart from other algorithmic stablecoins. For example, Frax’s Curve AMO module allows FRAX to directly mint FRAX into Curve’s meta pool in addition to USDC to tighten the peg while knowing exactly how much collateral it has access to if FRAX were to break its peg.

Curve’s AMO can call the AMO minter’s function mintFraxForAMO and then its own function metapoolDeposit to inject liquidity to Curve. The AMO minter is free to mint as long as the destination AMO’s mint cap and the new global collateral ratio are not breached.

function mintFraxForAMO(address destination_amo, uint256 frax_amount) external onlyByOwnGov validAMO(destination_amo) {
    int256 frax_amt_i256 = int256(frax_amount);

    // Make sure you aren't minting more than the mint cap
    require((frax_mint_sum + frax_amt_i256) <= frax_mint_cap, "Mint cap reached");
    frax_mint_balances[destination_amo] += frax_amt_i256;
    frax_mint_sum += frax_amt_i256;

    // Make sure the FRAX minting wouldn't push the CR down too much
    // This is also a sanity check for the int256 math
    uint256 current_collateral_E18 = FRAX.globalCollateralValue();
    uint256 cur_frax_supply = FRAX.totalSupply();
    uint256 new_frax_supply = cur_frax_supply + frax_amount;
    uint256 new_cr = (current_collateral_E18 * PRICE_PRECISION) / new_frax_supply;
    require(new_cr >= min_cr, "CR would be too low");

    // Mint the FRAX to the AMO
    FRAX.pool_mint(destination_amo, frax_amount);

    // Sync
    syncDollarBalances();
}
// 1. Deposit USDC to 3pool
// 2. Deposit 3CRV and FRAX to meta pool
function metapoolDeposit(uint256 _frax_amount, uint256 _collateral_amount) external onlyByOwnGov returns (uint256 metapool_LP_received) {
    uint256 threeCRV_received = 0;
    if (_collateral_amount > 0) {
        // Approve the collateral to be added to 3pool
        collateral_token.approve(address(three_pool), _collateral_amount);

        // Convert collateral into 3pool
        uint256[3] memory three_pool_collaterals;
        three_pool_collaterals[1] = _collateral_amount;
        {
            uint256 min_3pool_out = (_collateral_amount * (10 ** missing_decimals)).mul(liq_slippage_3crv).div(PRICE_PRECISION);
            three_pool.add_liquidity(three_pool_collaterals, min_3pool_out);
        }

        // Approve the 3pool for the metapool
        threeCRV_received = three_pool_erc20.balanceOf(address(this));

        // WEIRD ISSUE: NEED TO DO three_pool_erc20.approve(address(three_pool), 0); first before every time
        // May be related to https://github.com/vyperlang/vyper/blob/3e1ff1eb327e9017c5758e24db4bdf66bbfae371/examples/tokens/ERC20.vy#L85
        three_pool_erc20.approve(frax3crv_metapool_address, 0);
        three_pool_erc20.approve(frax3crv_metapool_address, threeCRV_received);
    }
    
    // Approve the FRAX for the metapool
    FRAX.approve(frax3crv_metapool_address, _frax_amount);

    {
        // Add the FRAX and the collateral to the metapool
        uint256 min_lp_out = (_frax_amount.add(threeCRV_received)).mul(slippage_metapool).div(PRICE_PRECISION);
        metapool_LP_received = frax3crv_metapool.add_liquidity([_frax_amount, threeCRV_received], min_lp_out);
    }

    return metapool_LP_received;
}

Later when governance decides it is time to remove liquidity (FRAX or USDC or both), it can call the functions metapoolWithdrawFrax/metapoolWithdraw3pool/metapoolWithdrawAtCurRatio

to withdraw FRAX from the metapool and USDC from the 3pool. Optionally it can choose to burn the FRAX withdrawn in the same transaction (burnFrax can also be called on its own). The AMO minter burns the FRAX taken from the AMO and decreases the AMO’s FRAX mint balances.

function burnFraxFromAMO(uint256 frax_amount) external validAMO(msg.sender) {
    int256 frax_amt_i256 = int256(frax_amount);

    // Burn first
    FRAX.pool_burn_from(msg.sender, frax_amount);

    // Then update the balances
    frax_mint_balances[msg.sender] -= frax_amt_i256;
    frax_mint_sum -= frax_amt_i256;

    // Sync
    syncDollarBalances();
}

Let A Thousand Coins Shine

Frax is not the only algorithmic stable in the market. It will not be the only one either.

Ultimately, stablecoins are products that fulfill a specific Job-To-be-done (JTBD). Are you a speculator looking to lever up? Overcollateralization is right for you. Go over there for $DAI and $MIM. Are you a DAO managing $10 million+ of treasuries? Go over there for $FEI and $FRAX. Are you an institutional investor looking for low-risk crypto exposure? Go over there for $UST and $UXD. To each, their own.

Each stablecoin has noticeably different risk profiles and use cases. Even so, we are optimistic that $FRAX will remain a benchmark for future DeFi protocols because it has got the 3 Killer Apps: Curve-like property, peg mechanism and AMOs.

Subscribe to jackchong.eth
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.