Stablecoins in DeFi Projects

Stablecoins

Stablecoins are cryptocurrencies whose value is pegged (or anchored) to a stable asset, such as fiat currency, gold, or other commodities. Stablecoins aim to combine the instant processing, security, and privacy of cryptocurrencies with the stable value of traditional fiat currencies. Stablecoins are an important component in the Web3 world as they provide a way to avoid the volatility of the crypto market within the decentralized finance (DeFi) ecosystem.

Stablecoins can be broadly categorized as follows (there are various classification methods, with no standard approach, so a basic understanding suffices):

  1. Fiat-Collateralized Stablecoins: This type of stablecoin is backed 1:1 by real-world fiat currencies like USD, EUR, etc. For every stablecoin issued, there is a corresponding amount of fiat currency held in a bank account as backing. Examples include USDT (Tether), USDC (USD Coin), and PAX (Paxos Standard).
  2. Crypto-Collateralized Stablecoins: These stablecoins are backed by other crypto assets. To address the volatility of crypto asset prices, these stablecoins are usually over-collateralized, meaning the value of the collateralized crypto assets exceeds the value of the stablecoin itself. A representative crypto-collateralized stablecoin is DAI (issued by the MakerDAO system).
  3. Algorithmic Stablecoins: Algorithmic stablecoins are not backed by actual assets but instead use algorithms to adjust the supply to stabilize the coin's value. This type of stablecoin attempts to maintain a value equal to the pegged asset by expanding or contracting the circulating supply, or through incentive measures.

Contract Implementation

Next, we'll attempt to implement a collateralized stablecoin.

Project address: https://github.com/lessurlx/foundry-Defi-StableCoin

DecentralizedStableCoin

Implement a decentralized stablecoin contract DecentralizedStableCoin.sol

forge install openzeppelin/openzeppelin-contracts --no-commit

Add remapping in foundry.toml:

remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts"]

We can quickly complete the minting and burning logic of the stablecoin using OpenZeppelin's template. The contract code is as follows:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {ERC20Burnable, ERC20} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

/**
 * @title Decentralized Stable Coin
 * Collateral: Exogenous (ETH & BTC)
 * Minting: Algorithmic
 * Relative Stability: Pegged to USD
 *
 * This is the contract meant to be governed by DSCEngine. This contract is just the ERC20 implementation of our stablecoin system.
 */
contract DecentralizedStableCoin is ERC20Burnable, Ownable {
    error DecentralizedStableCoin__AmountMustBeMoreThanZero();
    error DecentralizedStableCoin__BurnAmountExceedsBalance();
    error DecentralizedStableCoin__NotZeroAddress();

    constructor() ERC20("DecentralizedStableCoin", "DSC") Ownable(msg.sender) {}

    function burn(uint256 _amount) public override onlyOwner {
        uint256 balance = balanceOf(msg.sender);
        if (_amount <= 0) {
            revert DecentralizedStableCoin__AmountMustBeMoreThanZero();
        }

        if (balance < _amount) {
            revert DecentralizedStableCoin__BurnAmountExceedsBalance();
        }
        super.burn(_amount);
    }

    function mint(
        address _to,
        uint256 _amount
    ) public onlyOwner returns (bool) {
        if (_to == address(0)) {
            revert DecentralizedStableCoin__NotZeroAddress();
        }
        if (_amount <= 0) {
            revert DecentralizedStableCoin__AmountMustBeMoreThanZero();
        }
        _mint(_to, _amount);
        return true;
    }
}

OracleLib

In this stablecoin project, it's crucial to constantly update the value of the collateral assets. If the value of the collateral assets plummets, it could lead to the collapse of our stablecoin. The role of the oracle is to obtain the asset value, and we are using Chainlink's price feed here. The link is as follows: https://docs.chain.link/data-feeds/price-feeds/addresses?network=ethereum&page=1#sepolia-testnet

Chainlink updates the latest asset value within each heartbeat interval. Taking BTC as an example, its HeartBeat is 3600s.

Sepolia Testnet

We can create our own library file, named OracleLib.sol, to check if this heartbeat is valid. If the last heartbeat time is too long, we can understand that Chainlink has failed, and the asset value at this time cannot be determined, so we should stop the contract service.

The implementation details are as follows:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

/*
 * @title OracleLib
 * @notice This library is used to check the Chainlink Oracle for stale data
 * If a price is stale, the function will revert, and render the DSCEngine unusable
 * We want the DSCEngine to freeze if prices become stale.
 *
 * So if the chainlink network explodes and you have a lot of money locked in the protocol, that's too bad
 */
library OracleLib {
    error OracleLib__StalePrice();

    uint256 private constant TIMEOUT = 3 hours;

    function staleCheckLatestRoundData(
        AggregatorV3Interface priceFeed
    ) public view returns (uint80, int256, uint256, uint256, uint80) {
        (
            uint80 roundId,
            int256 answer,
            uint256 startedAt,
            uint256 updatedAt,
            uint80 answeredInRound
        ) = priceFeed.latestRoundData();

        uint256 secondsSince = block.timestamp - updatedAt;
        if (secondsSince > TIMEOUT) {
            revert OracleLib__StalePrice();
        }
        return (roundId, answer, startedAt, updatedAt, answeredInRound);
    }
}

DSCEngine

After having the contract code for the stablecoin DSC above, we still need a DSCEngine to ensure the stability of the stablecoin, with the goal of maintaining 1 DSC equal to 1 dollar in value. The stablecoin is stabilized algorithmically and is collateralized by external assets (such as ETH and BTC). This system is designed to be very simple, similar to MakerDAO's DAI, but without governance and fees, only supporting WETH and WBTC as collateral.

Interaction Process

  1. Users deposit collateral (ETH, BTC, etc.) into the DSCEngine contract.
  2. Based on the current market value of the collateral and the health factor, users can mint a corresponding amount of DSC.
  3. Users can redeem their collateral at any time, but must first burn an equivalent value of DSC.
  4. If a user's health factor falls below the minimum threshold, their collateral will be at risk of liquidation.
  5. Liquidators can burn the user's DSC and receive collateral of corresponding value at a discounted price.

Stability Guarantee

  • Over-collateralization and Health Factor: The requirement for over-collateralization ensures that even with market price fluctuations, the value of the collateral will exceed the value of the minted DSC.
  • Liquidation Mechanism: If the value of the collateral falls below a certain threshold, the liquidation mechanism is triggered, allowing other users to liquidate unhealthy collateral, protecting the overall health of the system.
  • Price Feeds: Real-time market price feeds ensure the accuracy of collateral value, which is crucial for monitoring collateral value and maintaining stablecoin value.

Through these mechanisms, DSCEngine aims to maintain the stability of DSC through algorithms and market mechanisms. Although it doesn't directly implement a price stabilization mechanism, the DSCEngine contract maintains the stability of DSC tokens through a series of financial health checks and incentive measures:

  1. Over-Collateralization: Users must deposit collateral value exceeding the amount of DSC they want to mint, defined by LIQUIDATION_THRESHOLD. This means if a user wants to mint $100 worth of DSC, they might need to deposit $200 worth of ETH or BTC. This over-collateralization requirement provides a buffer for price fluctuations.
  2. Real-Time Price Feeds: The contract uses Chainlink's AggregatorV3Interface to obtain the latest market prices for collateral. This ensures that the collateral value assessment is synchronized with market prices, reflecting the real value of collateral assets in real-time.
  3. Health Factor: The contract calculates and monitors the health factor of each user, which is the ratio of the user's collateral value to their DSC debt. If a user's health factor falls below MIN_HEALTH_FACTOR, their collateral may be liquidated. This mechanism ensures that users cannot mint too much DSC without adding additional collateral.
  4. Liquidation Mechanism: When a user's health factor is too low, meaning their collateral is insufficient to support their DSC debt, other participants can liquidate these unhealthy collateral positions. Liquidators receive collateral at a price below market value (incentivized by LIQUIDATION_BONUS), while burning the corresponding DSC, reducing the DSC supply in the market, helping to maintain DSC price stability.
  5. Interaction Restriction (Non-Reentrancy): The contract inherits OpenZeppelin's ReentrancyGuard, which prevents reentrancy attacks, an important security measure to protect user assets and maintain overall contract stability.

Through these mechanisms, DSCEngine attempts to maintain the stable value of DSC, enabling it to serve as a stable store of value and medium of exchange in the cryptocurrency market. However, this stability depends on the effective execution of the liquidation process and accurate market reaction to collateral prices. If market prices drop rapidly, liquidations may not occur quickly enough, putting the system at risk of insolvency. Additionally, the system's stability also depends on the accuracy and reliability of Chainlink price feeds.

Function Introduction

Below is an overview of the functions in the contract:

Constructor (constructor)

  • Initializes the contract, sets the price feed addresses and acceptable collateral addresses, and stores this information in state variables.
  • Initializes the reference to the DecentralizedStableCoin contract.

Calculate Health Factor (_healthFactor, _calculateHealthFactor)

  • Check Total Minted: If the user hasn't minted any DSC (totalDscMinted is 0), the health factor is set to type(uint256).max, indicating the user's health factor is at maximum as there's no debt risk.
  • Adjust Collateral Value: Calculates the adjusted value of the collateral by multiplying the user's total collateral value (collateralValueInUsd) by the liquidation threshold (LIQUIDATION_THRESHOLD) and dividing by the liquidation precision (LIQUIDATION_PRECISION). The threshold is typically set to the over-collateralization ratio required (e.g., 200%).
  • Calculate Health Factor: Finally, multiplies the adjusted collateral value by a precision value, then divides by the total amount of DSC minted by the user. This result is the health factor, representing the ratio of the user's collateral value to their DSC debt.

Deposit Collateral (depositCollateral)

  • Allows users to deposit specified collateral.
  • Triggers an event (CollateralDeposited) recording the collateral deposit.
  • Calls the transferFrom method of the ERC20 token to transfer the user's tokens to the contract address.

Redeem Collateral (redeemCollateral)

  • Update Collateral Record: Subtracts the collateral amount from the from user's account for the corresponding tokenCollateralAddress. The s_collateralDeposited state variable records the total amount of each collateral deposited by the user.
  • Trigger Event: Triggers the CollateralRedeemed event, recording the transfer of collateral from from to to, along with the involved collateral address and amount.
  • Transfer Collateral: Uses the IERC20(tokenCollateralAddress).transfer function to transfer collateral from the contract address to the to account.
  • Check Transfer Result: Checks if the transfer function call was successful. If not, the contract will revert with a DSCEngine__TransferFailed() error.
  • Check Health Factor: If the health factor is too low after execution, all operations are reverted.

Mint DSC (mintDsc)

  • Allows users to mint DSC.
  • Checks the health factor to ensure the user has sufficient collateral.

Burn DSC (burnDsc)

  • Update Minting Record: Subtracts the amount of DSC minted from the onBehalfOf user's account. The s_DSCMinted[onBehalfOf] state variable records the total amount of DSC minted by the user.
  • Transfer Tokens: Uses the i_dsc.transferFrom function to transfer DSC equal to amountDscToBurn from the dscFrom account to the contract address. This step is usually to retrieve tokens from the user's account for subsequent destruction.
  • Check Transfer Result: Checks if the transferFrom function call was successful. If not, the contract will revert with a DSCEngine__TransferFailed() error.
  • Burn Tokens: Calls the i_dsc.burn function to burn the DSC amount transferred from the dscFrom account. This step actually reduces the total supply of DSC.
  • Check Health Factor: If the health factor is too low after execution, all operations are reverted.

Deposit and Mint (depositCollateralAndMintDsc)

  • Allows users to deposit collateral and mint DSC.
  • Calls depositCollateral and mintDsc

Redeem Collateral for DSC (redeemCollateralForDsc)

  • Allows users to burn DSC and redeem a corresponding amount of collateral.
  • Calls burnDsc and redeemCollateral

Liquidate (liquidate)

  • Check Health Factor: Calculates the current health factor of the input user (user) by calling the _healthFactor function. If this health factor is greater than or equal to MIN_HEALTH_FACTOR, liquidation is not allowed as the user's collateral is still considered healthy, and the contract will revert with a DSCEngine__HealthFactorOk() error.
  • Calculate Collateral Value: Calls the getTokenAmountFromUsd function to calculate the amount of collateral equivalent to the USD value of DSC the liquidator will liquidate. For example, if debtToCover is 100 DSC, the liquidator needs to receive collateral worth 100 USD.
  • Calculate Liquidation Bonus: The liquidator receives an additional bonus, calculated using the LIQUIDATION_BONUS and LIQUIDATION_PRECISION constants. In this example, the bonus is 10% of the liquidated collateral value, so the total collateral value the liquidator receives is 110% of the DSC value they liquidate.
  • Redeem Collateral: The liquidator will receive the total collateral, including the base collateral value plus the liquidation bonus. This step seems to reference an internal function _redeemCollateral not defined in the code. The purpose of this function is to transfer the calculated amount of collateral (including the bonus) from the unhealthy user's account to the liquidator's account.
  • Burn DSC: During liquidation, the amount of DSC representing debtToCover will be burned, reducing the total DSC supply in the system. This may also involve an internal function _burnDsc not defined in the code.
  • Check Health Factor Improvement: After liquidation, the user's health factor is recalculated to confirm that the liquidation action indeed improved the user's health factor. If the user's health factor hasn't improved after liquidation, the contract will revert with a DSCEngine__HealthFactorNotImproved() error.
  • Check Liquidator's Health Factor: Finally, the contract will check if the liquidator's health factor still meets the requirements to ensure the liquidation action hasn't compromised the liquidator's own financial health.

Get Account Information (_getAccountInformation)

  • Returns the total amount of DSC minted by the user and the USD value of their collateral.

Check and Revert Health Factor (_revertIfHealthFactorIsBroken)

  • Checks if the user's health factor is below the minimum value and reverts the transaction if necessary.

Get Token Amount from USD (getTokenAmountFromUsd)

  • Converts a USD amount to the corresponding token amount.

Get Account Collateral Value (getAccountCollateralValue)

  • Calculates the total value of all collateral for a user (in USD).

Get USD Value (getUsdValue)

  • Calculates the USD value of a given amount of tokens.

Get Account Information (getAccountInformation)

  • An externally callable function that returns the total amount of DSC minted by the user and the USD value of their collateral.

Constants and Variables

The contract also defines some error handling methods and state variables, as well as events related to collateral. Additionally, the contract includes some important constants and state variables, as well as modifiers for input checking. Here's a brief overview of these components:

Constants

  • LIQUIDATION_THRESHOLD: Liquidation threshold, set to 50, meaning users must provide at least 200% over-collateralization.
  • LIQUIDATION_BONUS: Liquidation bonus, set to 10%, i.e., the collateral discount received during liquidation.
  • LIQUIDATION_PRECISION: Liquidation precision, used to maintain numerical precision in liquidation calculations.
  • MIN_HEALTH_FACTOR: Minimum health factor, used to determine if an account is healthy enough to prevent liquidation.
  • PRECISION: Precision used for internal calculations, typically 1e18.
  • ADDITIONAL_FEED_PRECISION: Additional precision used for converting from price feeds to token value.
  • FEED_PRECISION: Base precision of price feeds.

State Variables

  • i_dsc: An immutable private reference to the DecentralizedStableCoin contract.
  • s_priceFeeds: A mapping to store price feed addresses for each token.
  • s_collateralDeposited: A mapping to store the amount of each type of collateral deposited by each user.
  • s_DSCMinted: A mapping to store the amount of DSC minted by each user.
  • s_collateralTokens: An array to store the addresses of acceptable collateral tokens.

Implementation Details

Install Chainlink library:

forge install smartcontractkit/[email protected] --no-commit

Add mapping in remappings:

"@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/"

The implementation code is as follows:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import {DecentralizedStableCoin} from "./DecentralizedStableCoin.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
import {OracleLib} from "./libraries/OracleLib.sol";

/*
 * @title DSCEngine
 *
 * The system is designed to be as minimal as possible, and have the tokens maintain a 1 token == $1 peg.
 * This stablecoin has the properties:
 *  - Exogenous Collateral (ETH & BTC)
 *  - Dollar Pegged
 *  - Algoritmically Stable
 *
 *  It is similar to DAI had no governance, no fees, and was only backed by WETH and WBTC.
 */
contract DSCEngine is ReentrancyGuard {
    ///////////////////
    // Errors
    ///////////////////
    error DSCEngine__NeedsMoreThanZero();
    error DSCEngine__TokenAddressesAndPriceFeedAddressesMustBeSameLength();
    error DSCEngine__NotAllowedToken();
    error DSCEngine__TransferFailed();
    error DSCEngine__BreaksHealthFactor(uint256 healthFactor);
    error DSCEngine__MintFailed();
    error DSCEngine__HealthFactorOK();
    error DSCEngine__HealthFactorNotImproved();

    ///////////////////
    // Type
    ///////////////////
    using OracleLib for AggregatorV3Interface;

    ///////////////////
    // State Variables
    ///////////////////
    DecentralizedStableCoin private immutable i_dsc;

    uint256 private constant LIQUIDATION_THRESHOLD = 50; // This means you need to be 200% over-collateralized
    uint256 private constant LIQUIDATION_BONUS = 10; // This means you get assets at a 10% discount when liquidating
    uint256 private constant LIQUIDATION_PRECISION = 100;
    uint256 private constant MIN_HEALTH_FACTOR = 1e18;
    uint256 private constant PRECISION = 1e18;
    uint256 private constant ADDITIONAL_FEED_PRECISION = 1e10;
    uint256 private constant FEED_PRECISION = 1e8;

    mapping(address token => address priceFeed) private s_priceFeeds;
    mapping(address user => mapping(address token => uint256 amount))
        private s_collateralDeposited;
    mapping(address user => uint256 amountDscMinted) private s_DSCMinted;
    address[] private s_collateralTokens;

    ///////////////////
    // Events
    ///////////////////
    event CollateralDeposited(
        address indexed user,
        address indexed token,
        uint256 indexed amount
    );
    event CollateralRedeemed(
        address indexed redeemFrom,
        address indexed redeemTo,
        address token,
        uint256 amount
    ); // if redeemFrom != redeemedTo, then it was liquidated

    ///////////////////
    // Modifiers
    ///////////////////
    modifier moreThanZero(uint256 _amount) {
        if (_amount == 0) {
            revert DSCEngine__NeedsMoreThanZero();
        }
        _;
    }

    modifier isAllowedToken(address token) {
        if (s_priceFeeds[token] == address(0)) {
            revert DSCEngine__NotAllowedToken();
        }
        _;
    }

    ///////////////////
    // Functions
    ///////////////////
    constructor(
        address[] memory tokenAddresses,
        address[] memory priceFeedAddress,
        address dscAddress
    ) {
        if (tokenAddresses.length != priceFeedAddress.length) {
            revert DSCEngine__TokenAddressesAndPriceFeedAddressesMustBeSameLength();
        }
        for (uint256 i = 0; i < tokenAddresses.length; i++) {
            s_priceFeeds[tokenAddresses[i]] = priceFeedAddress[i];
            s_collateralTokens.push(tokenAddresses[i]);
        }
        i_dsc = DecentralizedStableCoin(dscAddress);
    }

    ///////////////////
    // External Functions
    ///////////////////
    function depositCollateralAndMintDsc(
        address tokenCollarteralAddress,
        uint256 amountCollateral,
        uint256 amountDscToMint
    ) external {
        depositCollateral(tokenCollarteralAddress, amountCollateral);
        mintDsc(amountDscToMint);
    }

    /*
     * @notice Deposit collateral to mint DSC
     * @param tokenCollarteralAddress The address of the collateral token
     * @param amountCollateral The amount of collateral to deposit
     */
    function depositCollateral(
        address tokenCollarteralAddress,
        uint256 amountCollateral
    )
        public
        moreThanZero(amountCollateral)
        isAllowedToken(tokenCollarteralAddress)
        nonReentrant
    {
        s_collateralDeposited[msg.sender][
            tokenCollarteralAddress
        ] += amountCollateral;
        emit CollateralDeposited(
            msg.sender,
            tokenCollarteralAddress,
            amountCollateral
        );
        bool success = IERC20(tokenCollarteralAddress).transferFrom(
            msg.sender,
            address(this),
            amountCollateral
        );
        if (!success) {
            revert DSCEngine__TransferFailed();
        }
    }

    function redeemCollateralForDsc(
        address tokenCollateralAddress,
        uint256 amountCollateral,
        uint256 amountDscToBurn
    ) external {
        burnDsc(amountDscToBurn);
        redeemCollateral(tokenCollateralAddress, amountCollateral);
        // redeemCollateral already checks health factor
    }

    function redeemCollateral(
        address tokenCollateralAddress,
        uint256 amountCollateral
    ) public moreThanZero(amountCollateral) nonReentrant {
        _redeemCollateral(
            tokenCollateralAddress,
            amountCollateral,
            msg.sender,
            msg.sender
        );
        _revertIfHealthFactorIsBroken(msg.sender);
    }

    /*
     * @notice follows CEI
     * @param amountDscToMint The amount of decentralized stablecoin to mint
     * @notice they must have more collateral value than the minimum threshold
     */
    function mintDsc(
        uint256 amountDscToMint
    ) public moreThanZero(amountDscToMint) nonReentrant {
        s_DSCMinted[msg.sender] += amountDscToMint;
        // if they minted too much
        _revertIfHealthFactorIsBroken(msg.sender);
        bool minted = i_dsc.mint(msg.sender, amountDscToMint);
        if (!minted) {
            revert DSCEngine__MintFailed();
        }
    }

    function burnDsc(uint256 amount) public moreThanZero(amount) {
        _burnDsc(amount, msg.sender, msg.sender);
        // This would never hit...
        _revertIfHealthFactorIsBroken(msg.sender);
    }

    /*
     * @param collateral The erc20 collateral address to liquidate from the user
     * @param user The user who has broken the health factor. Their _healthFactor should be below MIN_HEALTH_FACTOR
     * @param debtToCover The amount of DSC you want to burn to improve the users health factor
     * @notice You can partically liquidate a user
     * @notice You will get a liquidation bonus for taking the users funds
     * @notice This function working assumes the protocol will be roughly 200% over-collateralized in order for this to work
     * @notice A known bug would be if the protocol were 100% or less collateralized, then we wouldn't be able to incentive the liquidators.
     * For example, if the price of the collateral plummeted before anyone could be liquidated.
     */
    function liquidate(
        address collateral,
        address user,
        uint256 debtToCover
    ) external moreThanZero(debtToCover) nonReentrant {
        // need to check health factor before liquidating
        uint256 startingUserHealthFactor = _healthFactor(user);
        if (startingUserHealthFactor >= MIN_HEALTH_FACTOR) {
            revert DSCEngine__HealthFactorOK();
        }

        // We want to burn their DSC "debt"
        // And take their collateral
        uint256 tokenAmountFromDebtCovered = getTokenAmountFromUsd(
            collateral,
            debtToCover
        );

        // give them a 10% bonus
        // we should implement a feature to liquidate in the event the protocol is insolvent
        // and sweep extra amounts into a treasury
        uint256 bonusCollateral = (tokenAmountFromDebtCovered *
            LIQUIDATION_BONUS) / LIQUIDATION_PRECISION;

        _redeemCollateral(
            collateral,
            tokenAmountFromDebtCovered + bonusCollateral,
            user,
            msg.sender
        );
        _burnDsc(debtToCover, user, msg.sender);

        uint256 endingUserHealthFactor = _healthFactor(user);
        // This conditional should never hit, but just in case
        if (endingUserHealthFactor <= startingUserHealthFactor) {
            revert DSCEngine__HealthFactorNotImproved();
        }
        _revertIfHealthFactorIsBroken(msg.sender);
    }

    ///////////////////
    // Private Functions
    ///////////////////
    function _burnDsc(
        uint256 amountDscToBurn,
        address onBehalfOf,
        address dscFrom
    ) private {
        s_DSCMinted[onBehalfOf] -= amountDscToBurn;

        bool success = i_dsc.transferFrom(
            dscFrom,
            address(this),
            amountDscToBurn
        );
        // This conditional is hypothetically unreachable
        if (!success) {
            revert DSCEngine__TransferFailed();
        }
        i_dsc.burn(amountDscToBurn);
    }

    function _redeemCollateral(
        address tokenCollateralAddress,
        uint256 amountCollateral,
        address from,
        address to
    ) private {
        s_collateralDeposited[from][tokenCollateralAddress] -= amountCollateral;
        emit CollateralRedeemed(
            from,
            to,
            tokenCollateralAddress,
            amountCollateral
        );
        bool success = IERC20(tokenCollateralAddress).transfer(
            to,
            amountCollateral
        );
        if (!success) {
            revert DSCEngine__TransferFailed();
        }
    }

    function _getAccountInformation(
        address user
    )
        private
        view
        returns (uint256 totalDscMinted, uint256 collateralValueInUsd)
    {
        totalDscMinted = s_DSCMinted[user];
        collateralValueInUsd = getAccountCollateralValue(user);
    }

    /*
     * Returns how close to liquidation the user is
     * If a user goes below 1, then they are liquidated
     */
    function _healthFactor(address user) private view returns (uint256) {
        // total DSC minted
        // total collateral value
        (
            uint256 totalDscMinted,
            uint256 collateralValueInUsd
        ) = _getAccountInformation(user);

        return _calculateHealthFactor(totalDscMinted, collateralValueInUsd);
    }

    function _calculateHealthFactor(
        uint256 totalDscMinted,
        uint256 collateralValueInUsd
    ) internal pure returns (uint256) {
        if (totalDscMinted == 0) return type(uint256).max;
        uint256 collateralAdjustedForThreshold = (collateralValueInUsd *
            LIQUIDATION_THRESHOLD) / LIQUIDATION_PRECISION;
        return (collateralAdjustedForThreshold * PRECISION) / totalDscMinted;
    }

    // 1. Check health factor (do they have enough collateral?)
    // 2. Revert if they don't
    function _revertIfHealthFactorIsBroken(address user) internal view {
        uint256 userHealthFactor = _healthFactor(user);
        if (userHealthFactor < MIN_HEALTH_FACTOR) {
            revert DSCEngine__BreaksHealthFactor(userHealthFactor);
        }
    }

    function getTokenAmountFromUsd(
        address token,
        uint256 usdAmountInWei
    ) public view returns (uint256) {
        AggregatorV3Interface priceFeed = AggregatorV3Interface(
            s_priceFeeds[token]
        );
        (, int256 price, , , ) = priceFeed.staleCheckLatestRoundData();
        return
            (usdAmountInWei * PRECISION) /
            (uint256(price) * ADDITIONAL_FEED_PRECISION);
    }

    function getAccountCollateralValue(
        address user
    ) public view returns (uint256 totalCollateralValueInUsd) {
        // loop through each collateral token, get the amount they have deposited, and map it to the price, to get the USD value
        for (uint256 i = 0; i < s_collateralTokens.length; i++) {
            address token = s_collateralTokens[i];
            uint256 amount = s_collateralDeposited[user][token];
            totalCollateralValueInUsd += getUsdValue(token, amount);
        }

        return totalCollateralValueInUsd;
    }

    function getUsdValue(
        address token,
        uint256 amount
    ) public view returns (uint256) {
        AggregatorV3Interface priceFeed = AggregatorV3Interface(
            s_priceFeeds[token]
        );
        (, int price, , , ) = priceFeed.staleCheckLatestRoundData();
        return
            ((uint256(price) * ADDITIONAL_FEED_PRECISION) * amount) / PRECISION;
    }

    function getAccountInformation(
        address user
    )
        external
        view
        returns (uint256 totalDscMinted, uint256 collateralValueInUsd)
    {
        (totalDscMinted, collateralValueInUsd) = _getAccountInformation(user);
    }

    function getCollateralBalanceOfUser(
        address user,
        address token
    ) external view returns (uint256) {
        return s_collateralDeposited[user][token];
    }

    function getPrecision() external pure returns (uint256) {
        return PRECISION;
    }

    function getAdditionalFeedPrecision() external pure returns (uint256) {
        return ADDITIONAL_FEED_PRECISION;
    }

    function getLiquidationThreshold() external pure returns (uint256) {
        return LIQUIDATION_THRESHOLD;
    }

    function getLiquidationBonus() external pure returns (uint256) {
        return LIQUIDATION_BONUS;
    }

    function getLiquidationPrecision() external pure returns (uint256) {
        return LIQUIDATION_PRECISION;
    }

    function getMinHealthFactor() external pure returns (uint256) {
        return MIN_HEALTH_FACTOR;
    }

    function getCollateralTokens() external view returns (address[] memory) {
        return s_collateralTokens;
    }

    function getDsc() external view returns (address) {
        return address(i_dsc);
    }

    function getCollateralTokenPriceFeed(
        address token
    ) external view returns (address) {
        return s_priceFeeds[token];
    }

    function getHealthFactor(address user) external view returns (uint256) {
        return _healthFactor(user);
    }
}

Testing

Unit Tests

Here, a portion of unit test cases is provided. This is not a comprehensive set but serves as a starting point to inspire further testing. Readers can expand on these tests according to their specific needs.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import {Test} from "forge-std/Test.sol";
import {DeployDSC} from "../../script/DeployDSC.s.sol";
import {DecentralizedStableCoin} from "../../src/DecentralizedStableCoin.sol";
import {DSCEngine} from "../../src/DSCEngine.sol";
import {HelperConfig} from "../../script/HelperConfig.s.sol";
import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";

contract DSCEngineTest is Test {
    DeployDSC deployer;
    DecentralizedStableCoin dsc;
    DSCEngine dsce;
    HelperConfig config;
    address ethUsdPriceFeed;
    address btcUsdPriceFeed;
    address weth;

    address public USER = makeAddr("user");
    uint256 public constant AMOUNT_COLLATERAL = 10 ether;
    uint256 public constant STARTING_ERC20_BALANCE = 10 ether;

    function setUp() public {
        deployer = new DeployDSC();
        (dsc, dsce, config) = deployer.run();
        (ethUsdPriceFeed, btcUsdPriceFeed, weth, , ) = config
            .activeNetworkConfig();
        ERC20Mock(weth).mint(USER, STARTING_ERC20_BALANCE);
    }

    address[] public tokenAddresses;
    address[] public priceFeedAddresses;

    function testRevertsIfTokenLengthDoesntMatchPriceFeeds() public {
        tokenAddresses.push(weth);
        priceFeedAddresses.push(ethUsdPriceFeed);
        priceFeedAddresses.push(btcUsdPriceFeed);

        vm.expectRevert(
            DSCEngine
                .DSCEngine__TokenAddressesAndPriceFeedAddressesMustBeSameLength
                .selector
        );
        new DSCEngine(tokenAddresses, priceFeedAddresses, address(dsc));
    }

    // Price Tests
    function testGetUsdValue() public view {
        uint256 ethAmount = 15e18;
        uint expectedUsd = 30000e18;
        uint256 actualUsd = dsce.getUsdValue(weth, ethAmount);
        assertEq(actualUsd, expectedUsd);
    }

    function testGetTokenAmountFromUsd() public view {
        uint256 usdAmount = 100 ether;
        uint256 expectedWeth = 0.05 ether;
        uint256 actualWeth = dsce.getTokenAmountFromUsd(weth, usdAmount);
        assertEq(actualWeth, expectedWeth);
    }

    // depositCollateral Tests
    function testRevertsIfCollateralZero() public {
        vm.startPrank(USER);
        ERC20Mock(weth).approve(address(dsce), AMOUNT_COLLATERAL);
        vm.expectRevert(DSCEngine.DSCEngine__NeedsMoreThanZero.selector);
        dsce.depositCollateral(weth, 0);
        vm.stopPrank();
    }

    function testRevertsWithUnapprovedCollateral() public {
        ERC20Mock ranToken = new ERC20Mock();
        vm.startPrank(USER);
        vm.expectRevert(DSCEngine.DSCEngine__NotAllowedToken.selector);
        dsce.depositCollateral(address(ranToken), AMOUNT_COLLATERAL);
        vm.stopPrank();
    }

    modifier depositedCollateral() {
        vm.startPrank(USER);
        ERC20Mock(weth).approve(address(dsce), AMOUNT_COLLATERAL);
        dsce.depositCollateral(weth, AMOUNT_COLLATERAL);
        vm.stopPrank();
        _;
    }

    function testCanDepositCollateralAndGetAccountInfo()
        public
        depositedCollateral
    {
        (uint256 totalDscMinted, uint256 collateralValueInUsd) = dsce
            .getAccountInformation(USER);

        uint256 expectedTotalDscMinted = 0;
        uint256 expectedDepositAmount = dsce.getTokenAmountFromUsd(
            weth,
            collateralValueInUsd
        );
        assertEq(totalDscMinted, expectedTotalDscMinted);
        assertEq(AMOUNT_COLLATERAL, expectedDepositAmount);
    }
}

Fuzzing Tests

Fuzzing tests do not have the high level of determinism that unit tests do. They have a high degree of randomness, continuously calling functions in the contract randomly and sending random data. The advantages of fuzzing tests are that they can uncover edge cases that developers might not have thought of, and the testing process is highly automated, allowing for rapid testing of a large number of input scenarios.

We can configure some settings for fuzzing tests in the foundry.toml file.

[invariant]
runs = 128
depth = 128
fail_on_revert = false

Among these, 'runs' indicates how many times the test should be executed, 'depth' represents the depth of each test (i.e., how many function calls are made), and 'fail_on_revert' specifies whether the test should immediately stop when a revert occurs.

In fuzzing tests, the most crucial aspect is identifying the invariants in the contract. For example, in our stablecoin contract, one important invariant is: the value of the collateral must always be greater than or equal to the value of the minted DSC.

We can write a fuzzing test logic based on this invariant (Invariants.t.sol).

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import {Test, console} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {DeployDSC} from "../../script/DeployDSC.s.sol";
import {DSCEngine} from "../../src/DSCEngine.sol";
import {DecentralizedStableCoin} from "../../src/DecentralizedStableCoin.sol";
import {HelperConfig} from "../../script/HelperConfig.s.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Handler} from "./Handler.t.sol";

contract InvariantsTest is StdInvariant, Test {
    DeployDSC deployer;
    DSCEngine dsce;
    DecentralizedStableCoin dsc;
    HelperConfig config;
    address weth;
    address btc;
    Handler handler;

    function setUp() external {
        deployer = new DeployDSC();
        (dsc, dsce, config) = deployer.run();
        (, , weth, btc, ) = config.activeNetworkConfig();

        handler = new Handler(dsce, dsc);
        targetContract(address(handler));
    }

    function invariant_protocolMustHaveMoreValueThanTotalSupply() public view {
        uint256 totalSupply = dsc.totalSupply();
        uint256 totalWethDeposited = IERC20(weth).balanceOf(address(dsce));
        uint256 totalBtcDeposited = IERC20(btc).balanceOf(address(dsce));

        uint256 wethValue = dsce.getUsdValue(weth, totalWethDeposited);
        uint256 btcValue = dsce.getUsdValue(btc, totalBtcDeposited);

        console.log("wethValue: ", wethValue);
        console.log("btcValue: ", btcValue);
        console.log("totalSupply: ", totalSupply);
        console.log("times mint called: ", handler.timesMintIsCalled());
        assert(wethValue + btcValue >= totalSupply);
    }
}

We could have directly specified the current contract as the target for fuzzing tests (targetContract) in setUp, but this wouldn't be meaningful as it would randomly call functions. Without condition restrictions, it would naturally result in frequent reverts.

Therefore, we added a handler where we implemented the following restrictions:

mintDsc

This function is used to mint DSC tokens. It has the following conditions:

  • If no user has deposited collateral, the function returns immediately without minting any tokens.
  • It uses an address seed (addressSeed) to select a user address from the list of users who have deposited collateral (userWithCollateralDeposited) to perform the minting operation.
  • It calculates the maximum amount of DSC that the user can mint, which is half of the USD value of their collateral minus the amount of DSC they've already minted.
  • If the calculated maximum mintable amount is less than 0, or if the requested mint amount (amount) is 0, the function returns immediately.
  • The actual amount of DSC minted is limited between 0 and the maximum mintable amount.
  • It uses vm.startPrank and vm.stopPrank to simulate operations of a specific user, meaning the minting operation is executed as that user.

depositCollateral

This function is used to deposit collateral. Its conditions include:

  • It uses a seed (collateralSeed) to select the type of collateral to deposit (WETH or WBTC).
  • The amount of collateral deposited (amountCollateral) is limited between 1 and MAX_DEPOSIT_SIZE.
  • It uses vm.startPrank and vm.stopPrank to simulate msg.sender operations, allowing them to deposit collateral and approve transfer to the DSCEngine contract.
  • It adds msg.sender to the list of users who have deposited collateral.

redeemCollateral

This function is used to redeem collateral. Its conditions include:

  • It uses a seed (collateralSeed) to select the type of collateral to redeem (WETH or WBTC).
  • The amount of collateral redeemed (amountCollateral) is limited between 0 and the user's collateral balance in the DSCEngine contract.
  • If the requested redemption amount is 0, the function returns immediately.

The specific implementation code is as follows:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {DeployDSC} from "../../script/DeployDSC.s.sol";
import {DSCEngine} from "../../src/DSCEngine.sol";
import {DecentralizedStableCoin} from "../../src/DecentralizedStableCoin.sol";
import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
import {MockV3Aggregator} from "../mocks/MockV3Aggregator.sol";

contract Handler is Test {
    DSCEngine dsce;
    DecentralizedStableCoin dsc;

    ERC20Mock weth;
    ERC20Mock wbtc;
    MockV3Aggregator public ethUsdPriceFeed;

    uint256 MAX_DEPOSIT_SIZE = type(uint96).max;

    uint256 public timesMintIsCalled;
    address[] public userWithCollateralDeposited;

    constructor(DSCEngine _dscEngine, DecentralizedStableCoin _dsc) {
        dsce = _dscEngine;
        dsc = _dsc;

        address[] memory collateralTokens = dsce.getCollateralTokens();
        weth = ERC20Mock(collateralTokens[0]);
        wbtc = ERC20Mock(collateralTokens[1]);

        ethUsdPriceFeed = MockV3Aggregator(
            dsce.getCollateralTokenPriceFeed(address(weth))
        );
    }

    function mintDsc(uint256 amount, uint256 addressSeed) public {
        if (userWithCollateralDeposited.length == 0) {
            return;
        }
        address sender = userWithCollateralDeposited[
            addressSeed % userWithCollateralDeposited.length
        ];
        (uint256 totalDscMinted, uint256 collateralValueInUsd) = dsce
            .getAccountInformation(sender);
        uint256 maxDscToMint = (collateralValueInUsd / 2) - totalDscMinted;
        if (maxDscToMint < 0) {
            return;
        }

        amount = bound(amount, 0, uint256(maxDscToMint));
        if (amount == 0) {
            return;
        }

        vm.startPrank(sender);
        dsce.mintDsc(amount);
        vm.stopPrank();
        timesMintIsCalled++;
    }

    // redeem collateral
    function depositCollateral(
        uint256 collateralSeed,
        uint256 amountCollateral
    ) public {
        ERC20Mock collateral = _getCollateralFromSeed(collateralSeed);
        amountCollateral = bound(amountCollateral, 1, MAX_DEPOSIT_SIZE);

        vm.startPrank(msg.sender);
        collateral.mint(msg.sender, amountCollateral);
        collateral.approve(address(dsce), amountCollateral);
        dsce.depositCollateral(address(collateral), amountCollateral);
        vm.stopPrank();

        userWithCollateralDeposited.push(msg.sender);
    }

    function redeemCollateral(
        uint256 collateralSeed,
        uint256 amountCollateral
    ) public {
        ERC20Mock collateral = _getCollateralFromSeed(collateralSeed);
        uint256 maxCollateralToRedeem = dsce.getCollateralBalanceOfUser(
            address(collateral),
            msg.sender
        );
        amountCollateral = bound(amountCollateral, 0, maxCollateralToRedeem);
        if (amountCollateral == 0) {
            return;
        }
        dsce.redeemCollateral(address(collateral), amountCollateral);
    }

    // This breaks our invariant test suite!!
    // function updateCollateralPrice(uint96 newPrice) public {
    //     int256 newPriceInt = int256(uint256(newPrice));
    //     ethUsdPriceFeed.updateAnswer(newPriceInt);
    // }

    function _getCollateralFromSeed(
        uint256 collateralSeed
    ) private view returns (ERC20Mock) {
        if (collateralSeed % 2 == 0) {
            return weth;
        }
        return wbtc;
    }
}

Through this approach, the Handler contract provides a controlled environment for fuzzing tests, preventing extreme scenarios that might occur during testing, such as operations exceeding logical limits. This ensures that the tests are conducted within a reasonable operational range and can effectively reveal potential issues in the smart contract.

Author: LeapWhale

Copyright: This article is copyrighted by LeapWhale. If reproduced, please cite the source!