Compound V2 in Depth

Introduction

Compound is a decentralized protocol which establishes money markets with algorithmically set interest rates based on supply and demand, allowing users to frictionlessly exchange the time value of Ethereum assets*.

Compound V2 is one of the most important protocols on EVM chains. According to Defillama, it is the second most forked project by TVL.

In this post, we are going to analyze the most important components of Compound, primarily money markets (cTokens) and its governance model.

NOTE: You should have a good understanding of the basic Ethereum and Defi concepts to follow along. Primarily, you should be comfortable reading Solidity and the basics of Defi (erc20 tokens, incentives, etc.)

This post is divided in 2 parts:

→ The first part goes through the general protocol architecture.

→ The second part goes through hands-on examples that demonstrate everything that was covered.

NOTE: You can find the github repo here. We will go through the source in the second part, but it is advisable to read the code as we understand each topic.

Protocol Architecture

Money Markets

The first concept to understand is money markets. You can think of a money market as a smart contract that holds 2 assets:

  1. The underlying asset (Eth, Dai, USDC, etc.).

  2. The token representation of the underlying asset (cToken).

NOTE: There are cases where money markets hold more than 2 assets, but compound V2 doesn’t.

In simple terms, you deposit an asset (think of Eth) into a smart contract and get a representation of it (cEth) in return. This token will increase or decrease in value depending on the rules of the smart contract.

The main conceptual difference between a money market and a traditional lending / borrowing platform is that the former is on-chain (trustless) while the latter requires you trusting an intermediate (platform).

One of the main benefits of this is that you can read the code and know that something will be executed with certainty and you also know the exact amount of liquidity that there is.

cTokens

As previously mentioned, each market is implemented as a smart contract and the token representation in Compound is known as a “cToken”.

The cToken is basically an ERC20 compatible token with extra functionality.

You can mint cTokens by depositing the underlying token to the cToken smart contract.

*You can find the full API of a cToken contract here.

Some important characteristics of a cToken:

  • Each cToken will increase its value on a (almost) per block basis due to the interests earned.

  • You can use the cToken as collateral to borrow funds. You can also use it in other markets (it is an ERC20 compatible token).

Exchange Rate: The exchange rate between a cToken and the underlying asset e.g. (dai vs cDai or eth vs cEth) begins at 0.020 and increases at a rate equal to the compounding market interest rate.

exchange rate formula
exchange rate formula

Where:

getCash(): The amount of underlying balance owned by this cToken contract.

totalBorrows(): The amount of underlying currently loaned out by the market, and the amount upon which interest is accumulated to suppliers of the market.

totalReserves(): Reserves are an accounting entry in each cToken contract that represents a portion of historical interest set aside as cash which can be withdrawn or transferred through the protocol’s governance.

totalSupply(): The number of tokens currently in circulation in this cToken market.

NOTE: All cTokens have 8 decimals, while the underlying token can vary.

Interest Rates

Interest rates are updated on every block as long as the ratio of borrowed assets to supplied assets has changed. The amount of the rates depend on the specific implementation of the smart contract.

There is an external smart contract that calculates the rates, both borrow and supply.

Let’s take the cEther money market as an example.

If we want to find out about the borrow rate per block, we would call the borrowRatePerBlock function from the specific cToken contract. The cToken contract (cEther for our example) will call another external contract that will get the rates.

function borrowRatePerBlock() override external view returns (uint) {
        return interestRateModel.getBorrowRate(getCashPrior(), totalBorrows, totalReserves);
    }

This is the reason why each cToken may have different interest rate implementations. For the cEther contract, the interest rate contract is called “LegacyJumpRateModelV2” you can find it here.

Liquidations

A user who has negative account liquidity is subject to liquidation by other users of the protocol to return his/her account liquidity back to positive (i.e. above the collateral requirement). When a liquidation occurs, a liquidator may repay some or all of an outstanding borrow on behalf of a borrower and in return receive a discounted amount of collateral held by the borrower; this discount is defined as the liquidation incentive.

A user’s account liquidity (you can read more about it here) is the amount in USD that a user is able to borrow.

We are going to go through an example to grasp this better, but before that we need to understand the collateral factor.

The collateral factor is a percentage that ranges between 0-90% that represents the amount that a user can borrow from minting cTokens. The more stable and liquid an asset is, the higher the collateral factor and vice versa.

Ok, so let’s suppose that Alice supplies 1 Eth to Compound. The price of Eth is $1,000 usd and the collateral factor is 90%. This means that Alice will have an account liquidity of $900 usd (90% of $1,000).

Then Alice borrows 900 usd of DAI, her new account liquidity would be 0. If the price of Eth goes down, her account liquidity would be negative and liquidators would be able to trigger this liquidation.

*To see this in code, go to the file located under test/AccounLiquidity.t.sol

One last characteristic to understand is that the system doesn’t guarantee that a liquidation will take place. It provides incentives so users (liquidators) are constantly watching for a liquidation opportunity so they call the liquidation function and get some money in return.

Comptroller

The Comptroller is the risk management layer of the Compound protocol; it determines how much collateral a user is required to maintain, and whether (and by how much) a user can be liquidated. Each time a user interacts with a cToken, the Comptroller is asked to approve or deny the transaction.

You can think of the Comptroller as a provider and verifier contract that gives information to the cTokens and checks that a user can execute a given transaction.

* You can find the Comptroller here.

NOTE: This diagram is not 100% accurate as the architecture is implemented through upgradable proxies but the general concept remains intact.

Governance

One crucial aspect of smart contract systems is its governance model.

This is probably the most important governance smart contracts in EVM chains, it is used in many protocols.

This is a complex topic for a short post, so to simplify it, we can think of what governance is by asking the following questions:

  • Are the contracts upgradable ? If they are, Who can upgrade them ?

  • Are there any other special privileges in the contracts ? For example, can an admin key blacklist certain users ?

These are the most fundamental points that we should focus on smart contract systems. If there are privileged keys that can blacklist users, upgrade the contracts or pause them, then the system is not very trustless or secure.

Ok, let’s go back to Compound.

Compound V2 is built around a decentralized voting process. To understand this, it is important to know what upgradable proxies are, you can read about them here.

I am going to go through the most important aspects of the governance process, to simplify it as much as possible.

If we remember from the previous part, the Comptroller is the contract that basically does a bunch of safety checks for the cTokens.

The Comptroller is architected as an upgradable proxy, the name of the actual proxy is named the “Unitroller”, you can find the contract’s code here.

The Unitroller delegates all calls to the Comptroller singleton, but it also has some additional methods, primarily changing the implementation contract and changing the admin.

So there are a couple of important security things going on here.

First of all, if someone upgrades the implementation contract (we will do it later on as a poc) to a malicious address, and changes the admin, then all user’s funds will be lost as there is no way to get it back.

The process basically works by proposing a proposal, voting (favour or against) and executing the proposal if succeeded.

Let’s get into the low level process of how making changes to the contract works.

1. Propose

The first thing is to propose a proposal, and this is done on chain by calling the “propose” function from the GovernorBravoDelegate contract. You can find it here.

function propose(address[] memory targets, uint[] memory values, string[] memory signatures, bytes[] memory calldatas, string memory description) public returns (uint)

In order to be able to call the propose function, the proposer (msg.sender) should have at least x amount of COMP tokens or votes. This minimum amount of votes to propose a proposal is called the “proposalThreshold” and it is currently 25,000. This means that if you have less than 25,000 COMP tokens or votes, you are not able to be a proposer.

Once the proposal goes through, there is a voting delay of 13,140 blocks (around 2 days). This means that we need to wait 13,140 blocks to start the voting process .

2. Vote

Once the voting delay period has passed, the process of voting beginns.

*Users that have “votes” are the ones that are able to vote. For example, if you have 100 COMP tokens and you delegate them to yourself, then you have 100 votes. You can also delegate your 100 COMP tokens to someone else, deferring your votes.

The process is very simple, there is a function called “castVote” that voters call with their vote preference. (There are more voting functions but they are all pretty much the same).

function castVote(uint proposalId, uint8 support) external 

You just need to pass the id of the proposal and your vote support (0=against, 1=for, 2=abstain).

Once you vote, all the votes that your account owns will be summed up to the proposal’s state. For example, if you have 10,000 COMP tokens and you are the delegator of your own tokens, and vote in favour of a proposal, then that proposal will increase its “for” votes by 10,000.

The voting period currently lasts for 19,710 blocks (around 3 days).

Once the voting period ends, it is time to queue the proposal.

3. Queue

Once all the votes were gathered and the voting period ends, the proposal either “Succeeded” or got “Defeated”. If the proposal succeeded, then the next step can go through.

The proposal is successful if:

a) There were more votes in favour than against

b) The “for” votes are greater than the quorumVotes.

There is a minimum amount of “for” votes in order for the proposal to go through. Currently the minimum or "quorumVotes” is 400,000.

The next step (if the proposal succeeds) is to queue it to the Timelock contract. You can find the contract here.

Once the transaction has been queued to the Timelock contract there is a 172,800 seconds delay (2 days). Once the delay is over, the transaction can be executed.

4. Execute

After the 2 days Timelock delay has passed, the proposal can be executed.

This is simply done by calling the “execute” transaction from the GovernorBravoDelegate contract.

This function will execute the proposal from the context of the Timelock contract.

A more realistic architecture of how the governance contracts are implemented:

And visual representation of the proposal process:

https://docs.compound.finance/v2/governance/
https://docs.compound.finance/v2/governance/

If a protocol has a bad structured governance model (easy to manipulate), then the governance model becomes the security’s weakest link. As you can see from the previous explanation, the governance of a protocol like Compound has the power to make critical changes that affect (in a positive or negative way) all users.

In Compound’s case, the security of the protocol goes hand by hand with the COMP token valuation. If the total market cap is very low, the possibilities of someone taking control rise significantly.

Hands-On

For this next part, we are going to execute the most common operations in Compound.

You can find the github repo here (you should clone the repo to follow along).

NOTE: The examples are done by forking Ethereum mainnet at block 16401180 and using Foundry.

Governance (upgrading the Comptroller)

This is probably the most interesting example. We are going to go through the entire proposal lifecycle that we covered in the Governance section.

*The file is located at test/UpgradeComptroller.t.sol

In this example, we are going to create a proposal, vote, win it in our favour, and then execute it. The proposal is to change the Comptroller implementation to a malicious singleton.

Here is the code:

// SPDX-License-Identifier: MIT
pragma solidity >= 0.8.0;

import "forge-std/Test.sol";

import "../src/NewComptroller.sol";
import "./utils/TestUtils.sol";

/// @notice Example contract that upgrades the Comptroller.
/// This example also goes through the entire governance journey:
/// 1. Create the proposal
/// 2. Vote
/// 3. Queue it to the Timelock
/// 4. Execute the proposal
contract UpgradeComptrollerTest is Test, TestUtils {
    NewComptroller newComptroller;

    receive() external payable {}

    function setUp() public {
        // Fork mainnet at block 16_401_180.
        cheat.createSelectFork("mainnet", BLOCK_NUMBER);

        ///// We fund this test contract with 400K COMP tokens.
        uint256 TRANSFER_AMOUNT = 400_000 * 1e18;

        cheat.startPrank(0x2775b1c75658Be0F640272CCb8c72ac986009e38);
        comp.transfer(address(this), TRANSFER_AMOUNT);
        cheat.stopPrank();

        assertEq(comp.balanceOf(address(this)), TRANSFER_AMOUNT);
        /////

        ///// Now we delegate all votes to this address.
        comp.delegate(address(this));
        /////

        // We increase the block number by 1 so the votes are actionable.
        vm.roll(block.number + 1);
        assertEq(comp.getPriorVotes(address(this), block.number - 1), TRANSFER_AMOUNT);

        // We create the new singleton Comptroller.
        newComptroller = new NewComptroller();
    }

    /// @notice This test function goes through the complete governance model of Compound.
    /// It ends up upgrading the comptroller singleton.
    function testUpgradeComptroller() public {
        // 1. We CREATE and send the PROPOSAL.
        uint256 proposalId = propose();

        // We increase the block number by the voting delay.
        uint256 votingDelay = gBravo.votingDelay(); // 13140 blocks
        vm.roll(block.number + votingDelay + 1);

        // 2. We VOTE in favor of our proposal.
        gBravo.castVote(proposalId, 1); // 0=against, 1=for, 2=abstain

        // We increase the block number so it gets to the voting end.
        uint256 votingPeriod = gBravo.votingPeriod(); // 19710 blocks
        vm.roll(block.number + votingPeriod);

        // 3. We QUEUE the transaction to the timelock.
        gBravo.queue(proposalId);

        // We increase the timestamp number by the timelock delay.
        uint256 timelockDelay = timelock.delay();

        vm.warp(block.timestamp + timelockDelay);

        // 4. We EXECUTE the PROPOSAL.
        gBravo.execute(proposalId);

        // We accept the implementation.
        newComptroller.acceptImplementation(address(comptroller));

        // We check that the comptroller has been upgraded.
        (bool success, bytes memory data) =
            address(comptroller).staticcall(abi.encodeWithSignature("testImplementation()"));
        require(success);
        string memory result = abi.decode(data, (string));
        assertEq(result, "I am the new Comptroller");
    }

    function propose() internal returns (uint256) {
        address[] memory targets = new address[](1);
        targets[0] = address(comptroller);

        uint256[] memory values = new uint[](1);
        values[0] = 0;

        string[] memory signatures = new string[](1);
        signatures[0] = "";

        bytes[] memory calldatas = new bytes[](1);
        calldatas[0] = abi.encodeWithSignature("_setPendingImplementation(address)", address(newComptroller));

        string memory description = "Upgrades the Comptroller";

        uint256 proposalId = gBravo.propose(targets, values, signatures, calldatas, description);
        require(proposalId > 0, "Proposal failed");
        return proposalId;
    }
}
 

Supply & Redeem

In this example we are going to supply Eth to the CEth market and then redeem it.

*The file is located at test/SupplyAndRedeem.t.sol

The steps to supply to a Compound market and get a cToken in return are the following:

  1. Deposit the underlying asset by calling the “mint()” function.

The mint function from the cToken will:

  • Transfer the underlying token amount (mintAmount) to the cToken contract or msg.value for cEther.

  • Mint the corresponding cTokens (depositAmount / exchangeRate).

We will be mainly interacting with the cEther contract, you can find it here.

Here is the complete test case for:

a) calculating the exchange rate

b) supplying

c) redeeming

// SPDX-License-Identifier: MIT
pragma solidity >= 0.8.0;

import "forge-std/Test.sol";

import "./utils/TestUtils.sol";

/// @notice Example contract that supplies an asset to Compound and redeems it.
contract SupplyAndRedeemTest is Test, TestUtils {
    receive() external payable {}

    function setUp() public {
        // Fork mainnet at block 16401180.
        cheat.createSelectFork("mainnet", BLOCK_NUMBER);
    }

    /// @dev Calculates the exchange rate between eth and cEther.
    function getExchangeRate() internal returns (uint256) {
        // exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply.
        uint256 totalCash = cEther.getCash();
        assertEq(totalCash, address(cEther).balance);

        uint256 totalBorrows = cEther.totalBorrowsCurrent();
        assert(totalBorrows > 0);

        uint256 totalReserves = cEther.totalReserves();
        assert(totalReserves > 0);

        uint256 totalSupply = cEther.totalSupply();
        assert(totalSupply > 0);

        uint256 exchangeRate = 1e18 * (totalCash + totalBorrows - totalReserves) / totalSupply;
        return exchangeRate;
    }

    /// @notice Supplies Eth to Compound, checks balances, accrues interests, and redeems.
    function testSupplyAndRedeem() public {
        // We save the initial eth balance to compare it later on.
        uint256 initialEthBalance = address(this).balance;

        // Our initial cEther balance should be 0.
        assertEq(cEther.balanceOf(address(this)), 0);

        // We supply 1 ether.
        cEther.mint{value: 1 ether}();

        // We could get the exchange rate by calling "exchangeRateCurrent()" directly.
        // We calculate it ourselves for learning purposes.
        uint256 exchangeRate = getExchangeRate();

        // We get the balance of cEther after supplying.
        uint256 cEtherBalance = cEther.balanceOf(address(this));

        // We get the amount of cEther that we should have.
        uint256 mintTokens = 1 ether * 1e18 / exchangeRate;
        assertEq(cEtherBalance, mintTokens);

        // We incrase the block number by 1.
        vm.roll(block.number + 1);

        // We redeem all the cEther.
        require(cEther.redeem(cEther.balanceOf(address(this))) == 0, "redeem failed");

        // We should have 0 cEther.
        assertEq(cEther.balanceOf(address(this)), 0);

        // Eth + interests. We should have more eth with 1 block of interests.
        assert(address(this).balance > initialEthBalance);
    }
}
 

Borrow & Repay

In this example, we supply Eth as collateral, enter the markets, borrow Dai and then repay it.

*The file is located at test/BorrowAndRepay.t.sol

// SPDX-License-Identifier: MIT
pragma solidity >= 0.8.0;

import "forge-std/Test.sol";

import "./utils/TestUtils.sol";

/// @notice Example contract that borrows and repays from Compound.
contract BorrowAndRepayTest is Test, TestUtils {
    function setUp() public {
        // Fork mainnet at block 16401180.
        cheat.createSelectFork("mainnet", BLOCK_NUMBER);
    }

    /// @notice Supplies Eth to Compound, checks balances, accrues interest, and redeems.
    function testBorrowAndRepay() public {
        // We shouldn't have any Dai balance at this time.
        assertEq(dai.balanceOf(address(this)), 0);

        ///// We need to supply some eth for collateral.
        cEther.mint{value: 1 ether}();

        // We enter the markets.
        address[] memory cTokens = new address[](1);
        cTokens[0] = address(cEther);
        comptroller.enterMarkets(cTokens); // <- we enter here

        // Checks
        address[] memory assetsIn = comptroller.getAssetsIn(address(this));
        assertEq(assetsIn[0], address(cEther));

        /////

        ///// Now we borrow some dai.
        uint256 borrowAmount = 500 * 1e18; // 500 dai
        cDai.borrow(borrowAmount);

        // Checks .. We should have 500 dai
        assertEq(dai.balanceOf(address(this)), borrowAmount);

        /////

        ///// We repay the dai.
        dai.approve(address(cDai), borrowAmount);
        cDai.repayBorrow(borrowAmount);

        assertEq(dai.balanceOf(address(this)), 0);
    }
}

References:

Subscribe to Rodrigo Herrera Itie
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.