Restoring the Dai peg with the yVault

Yearn's innovative new yETH/strategy products have the potential to restore the Dai peg that's been off since the March market collapse, and I've spent some time reviewing the yETH/StrategyMKRVaultDAIDelegate contracts, so I wanted to assemble some notes I've taken over the past week below in hopes that others find it helpful when trying to understand the underlying mechanics and how it connects to Multi-Collateral Dai.

I am a smart contracts dev at the Maker Foundation.

Maker Vaults and Yearn Vaults

Maker vaults are not yearn Vaults (yVaults).

In my eyes, vault is a generic term for a contract that holds digital assets for a specific purpose. Maker vaults allow a user to join collateral to the protocol, mint Dai against that collateral, pay back that Dai, and a litany of other interesting atomic transactions.

yVaults also hold digital assets and serve a specific purpose. This article explains how the yVault-ETH, StrategyMKRVaultDaiDelegate, and yVault-DAI contracts cooperate to deposit latent ETH, draw Dai against, and deploy that Dai into an interest earning yVault, with realized profits being recycled through this process to maintain a pre-determined leverage ratio.

Orbxball is the author of this contract set, and uses the below definitions that this article will follow:

  • mVault: Maker Multi-Collateral Dai vault
  • yVault: Yearn vault, with different varietes: yVault-DAI & yVault-WETH
  • Strategy: the one here being: StrategyMKRVaultDaiDelegate

Link to current vault.

Link to current strategy.

Note: code comments denoted 'WB' are made by me solely for this article and are not part of the source code.

Architecture

Users deposit ETH into a yVault-ETH, the WETH in that yVault is deposited into a single mVault, an amount of Dai is drawn placing the mVault at a 200% collateralization ratio at the current ETH oracle price, and the drawn Dai is then deposited into a yVault-DAI. Dai profit earned from that yVault-DAI is swapped into WETH via UniswapV2Router and is then joined into the GemJoin collateral adapter, and draws a commensurate amount of Dai to place the mVault at 200% collateralization.

yVault-ETH

A standard yVault with the _token address variable set to the WETH token address: 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2. This yVault consists of the standard yVault functions, depositETH(), withdrawETH(uint), and withdrawAllETH(). This article is focused on the MCD related logic of the Strategy, and as such, will not discuss the yVault-ETH implementation in any further detail.

The key function in yVault-WETH is the earn() function. This function retrieves the available balance in the vault (WETH token balance * min / max) via the available(), transfers it to the Controller contract, and subsequently calls earn(address,uint) on the Controller contract supplying the given _amount to deposit to the strategy. Upon the earn(address,uint) call the Controller retrieves the strategy for yVault-WETH (StrategyMKRVaultDaiDelegate.sol), gets the WETH token address, transfers _amount to the strategy, and then calls Strategy.deposit().

An interesting function I'd like to highlight is the payable fallback function in the yVault-ETH:

function () external payable {
    if (msg.sender != address(token)) {
        depositETH();
    }
}

This function allows anyone but the token to send ETH directly to the contract, and have it routed to the depositETH() public function. Users may misinterpret the calling a specific function on the contract with sending ETH directly to the contract, potentially resulting in the loss of funds. This contract prevents that, as any ETH sent directly to the contract will be routed through the appropriate function.

StrategyMKRVaultDAIDelegate

Below are summaries of functions pertaining to the StrategyMKRVaultDAIDelegate contract.

deposit() public function

Controller calls the deposit() function on the Strategy contract.

function deposit() public {
    // WB: get token balance of this strategy
    uint _token = IERC20(token).balanceOf(address(this));
    // WB: if token balance is greater than zero...
    if (_token > 0) {
        // WB: get ETH price, there are two prices: 
        //    `read`: the current price
        //and `foresight`: OSM price one hour ahead
        uint p = _getPrice();
        // WB: see bullet point 1 below
        uint _draw = _token.mul(p).mul(c_base).div(c).div(1e18);
        //// approve adapter to use token amount
        // WB: check that this draw won't exceed the collateral type's debt ceiling
        require(_checkDebtCeiling(_draw), "debt ceiling is reached!");
        // WB: function to lock the ETH and draw the aforementioned amount
        _lockWETHAndDrawDAI(_token, _draw);

        //// approve yVaultDAI use DAI
        // WB: then redeposit the minted dai back into the yVault-DAI
        yVault(yVaultDAI).depositAll();
    }
}

Example using 1337 figurative token amount at current prices (8/30/20 10pm):

  1. bc -l <<< ' 1337 * 10 ^ 18 * 424402500000000000000 * 10000 / 20000 / 10 ^ 18 '
  • Returns: 283713071250000000000000
  • Notice half of the the 1337 ETH * current ETH price in USD

lockWETHAndDrawDAI internal function

function _lockWETHAndDrawDAI(uint wad, uint wadD) internal {
    // WB: get urn address (urn is the address of the vault)
    address urn = ManagerLike(cdp_manager).urns(cdpId);

    //// GemJoinLike(mcd_join_eth_a).gem().approve(mcd_join_eth_a, wad);
    // WB: join the wad collateral amount into the MCD GemJoin adapter
    GemJoinLike(mcd_join_eth_a).join(urn, wad);
    // WB: `frob` the `wad` ink and `_getDrawDart(,)` 
    ManagerLike(cdp_manager).frob(cdpId, toInt(wad), _getDrawDart(urn, wadD));
    // WB: this line isn't actually necessary, and should be removed, it is a remnant of dss-proxy-actions
    ManagerLike(cdp_manager).move(cdpId, address(this), wadD.mul(1e27));
    if (VatLike(vat).can(address(this), address(mcd_join_dai)) == 0) {
        VatLike(vat).hope(mcd_join_dai);
    }
    // WB: exit the drawn Dai from the MCD system
    DaiJoinLike(mcd_join_dai).exit(address(this), wadD);
}

The function uses generic MCD functions: join(), frob(), move(), and exit(). And is code re-use from the MakerDAO dss-proxy-actions library.

withdraw(IERC20 _asset) external function

Function to transfer the entire balance of the strategy, including dust, to the controller. Requires that msg.sender is the controller, and that the _asset address is not the want, dai, or yVaultDAI addresses. Performs the withdrawal using a standard ERC20 safeTransfer(controller, balance) call.

withdraw(uint _amount) external function

Allows controller to specify amount of funds to withdraw, as opposed to withdrawing the entire balance. Assesses a fee that it sends to the controller address, and sends the rest to the vault address. This function uses the following internal functions to carry out the task: withdrawSome(uint a_amount), wipe(uint wad), and freeWETH(uint wad) which in sequence repay Dai debt and unlock and withdraw WETH while improving the mVault's collateralization ratio.

harvest() public function

Although harvest() is a public function, it contains a require statement making it only callable by the strategist, harvester, or governance addresses.

Comments denoted with 'WB' are mine:

function harvest() public {
    require(msg.sender == strategist || msg.sender == harvester || msg.sender == governance, "!authorized");
    // WB: retrieve underlying yVaultDAI token balance * pricePerFulShare / wad
    uint v = getUnderlyingDai();
    uint d = getTotalDebtAmount();
    // WB: require realized profit in yVault-DAI
    require(v > d, "profit is not realized yet!");
    uint profit = v.sub(d);

    // swap 
    // WB: here Dai is swapped for WETH via UniswapRouterV2 with any amount of slippage
    _swap(_withdrawDaiMost(profit));

    // WB: get WETH balance of this strategy contract after the swap
    uint _want = IERC20(want).balanceOf(address(this));
    // WB: if above balance is greater than zero...
    if (_want > 0) {
        // WB: multiply WETH balance by performance fee and divide by max performance fee
        uint _fee = _want.mul(performanceFee).div(performanceMax);
        // WB: transfer the fee to the strategist
        IERC20(want).safeTransfer(strategist, _fee);
        // WB: subtract the fee from the WETH balance
        _want = _want.sub(_fee);
    }

    // WB: call deposit() again on this contract to lock the WETH into the mVault..
    //     and draw Dai commensurate to 200% collateralization ratio of the newly received WETH
    deposit();
}

mVault collateralization ratios will increase upon successful harvest, but will not return precisely to the target ratio. Should the mVault require rebalancing and there is no realized profit in the yVault-DAI, any user can call the rebalance() external function to return the mVault to the intended ratio.

rebalance() external function

function rebalance() external {
    // WB: multiply safe `c` ratio by `100`
    uint _safe = c.mul(1e2);
    // WB: get current mVault ratio
    uint _current = getmVaultRatio(0);
    // WB: if current mVault coll. ratio is less than base ratio * 100...
    if (_current < c_base.mul(1e2)) {
        // WB: and if current ratio is less than safe ratio...
        if (_current < _safe) {
            // WB: get total debt amount, see how this is retrieved from the MCD system below
            uint d = getTotalDebtAmount();
            uint diff = _safe.sub(_current);
            uint free = d.mul(diff).div(_safe);
            _wipe(_withdrawDaiLeast(free));
        }
    }
}

Total debt of a vault is not stored directly in the Vat, it requires some computation:

function getTotalDebtAmount() public view returns (uint) {
    // WB: art is a vault's normalized debt in the vat
    uint art;
    // WB: rate is a perpetually increasing number updated on every drip() call to the Jug
    uint rate;
    // WB: get the urn handler address
    address urnHandler = ManagerLike(cdp_manager).urns(cdpId);
    // WB: and pass it alongside the ilk bytes32 to the vat to get the current normalized debt
    (,art) = VatLike(vat).urns(ilk, urnHandler);
    // WB: subsequently retrieve the current rate from the Vat using the ilk bytes32
    (,rate,,,) = VatLike(vat).ilks(ilk);
    // WB: return the total debt: (normalized debt * perpetually increasing rate variable)
    return art.mul(rate).div(1e27);
}

forceRebalance(uint _amount) external function

A function only callable by the governance, strategist, or harvester addresses. Immediately wipes a specified amount of Dai debt with a call to _wipe(_withdrawDaiLest(_amount));.

function forceRebalance(uint _amount) external {
    require(msg.sender == governance || msg.sender == strategist || msg.sender == harvester, "!authorized");
    _wipe(_withdrawDaiLeast(_amount));
}

a note on the differing withdrawals

Exits from the system use the internal _withdrawDaiLeast(uint _amount) and harvests use _withdrawDaiMost(uint _amount).

The deep_withdrawDaiLeast(uint _amount) assesses a fee, while the shallow_withdrawDaiMost(uint _amount) doesn't. This ensures that fees are paid upon exit, but not when the protocol is recycling yVault-DAI profits.

concluding notes

Above is a non-exhaustive list of all the internal functions of the triforce yVault-ETH/StrategyMKRVaultDAIDelegate/yVault-DAI contract set, and is meant to give readers a basic understanding of the contract architecture. Should anyone be interested in more detail, please let me know on Twitter @wilbarnes.

Show Comments